1#!/usr/bin/env python3
2"""OneNote 2010 conflict pages: how they arise, what they store and how OneNote shows them.
3
4`sync LINUX OUTPUT` runs two disposable OneNote clients on one Samba notebook: client A edits
5two paragraphs offline while client B edits one of them and a table cell online; A then
6reconnects, which makes OneNote write a conflict page. The server file is kept before and
7after, A and B are screenshotted with the information bar, the page list shown, the conflict
8page open and its menu open, and A then deletes the conflict page through that menu.
9
10`cold NOTEBOOK OUTPUT [--delete]` cold-opens a notebook holding a conflict page in a fresh
11clone and screenshots the same views; `--delete` deletes the conflict page through the menu
12and captures the notebook OneNote leaves.
13"""
14import argparse
15from contextlib import ExitStack
16from concurrent.futures import ThreadPoolExecutor
17import base64
18import json
19from pathlib import Path
20import shutil
21import subprocess
22import sys
23import time
24import xml.etree.ElementTree as ET
25
26sys.path.insert(0, str(Path(__file__).resolve().parent))
27sys.path.insert(0, str(Path(__file__).resolve().parent / 'w7'))
28import native_runner as runner
29from native_runner import ROOT, clone, command, windows, vm
30import linux_vm
31
32FIXTURE = ROOT / 'corpus/native-ink/cold-ui-ink/notebook'
33SHARE = 'm6-conflict'
34ORIGINAL = 'Fictitious: café, 東京, مرحبا'
35OTHER_OUTLINE = 'Fictitious positioned outline.'
36CELL = 'Left cell'
37
38# Coordinates on OneNote 2010 maximized at 800x600, from the stage-5 and m8 captures: the
39# information bar's text, and the first conflict page listed under its page.
40BAR = (332, 100)
41FIRST_CONFLICT = (725, 142)
42
43
44def shot(name, output, label, script):
45 """Runs an AutoHotkey step on the clone, keeping its screenshot as `label.png`."""
46 (output / f'{label}.ahk').write_text(script)
47 result = windows.do_exec(script, target=name, timeout_ms=120000, shot_delay_ms=1500)
48 screenshot = result.pop('png_b64', None)
49 if screenshot:
50 (output / f'{label}.png').write_bytes(base64.b64decode(screenshot))
51 (output / f'{label}.json').write_text(json.dumps(result, indent=2))
52 if result.get('error') or result.get('exit') != 0:
53 raise RuntimeError(f'{label}: {result}')
54 return result
55
56
57def click(x, y):
58 return ('WinActivate("ahk_exe ONENOTE.EXE")\nCoordMode("Mouse", "Screen")\n'
59 f'Click({x}, {y})\nSleep(1200)\n')
60
61
62# Reports any dialog OneNote raised, so a confirmation shows in the step's record.
63DIALOG = ('if WinExist("ahk_class #32770 ahk_exe ONENOTE.EXE") {\n'
64 ' FileAppend("dialog: " WinGetTitle() " | " WinGetText() "`n", "*", "UTF-8")\n'
65 '}\n')
66
67
68def conflict_views(name, output, page, prefix, delete=False):
69 """Screenshots a page with a conflict page: the bar, the list shown, the conflict page and
70 its menu; `delete` then chooses Delete Conflict Page."""
71 shot(name, output, f'{prefix}-main', runner.navigation_script(page) + 'Sleep(2000)\n')
72 shot(name, output, f'{prefix}-shown', click(*BAR))
73 shot(name, output, f'{prefix}-conflict', click(*FIRST_CONFLICT))
74 shot(name, output, f'{prefix}-menu', click(*BAR))
75 if delete:
76 shot(name, output, f'{prefix}-delete', 'WinActivate("ahk_exe ONENOTE.EXE")\nSend("d")\nSleep(1500)\n' + DIALOG)
77 else:
78 shot(name, output, f'{prefix}-closed', 'WinActivate("ahk_exe ONENOTE.EXE")\nSend("{Esc}")\nSleep(800)\n')
79
80
81def cold(notebook, output, delete):
82 def interaction(name, output):
83 page = ET.parse(sorted((output / 'read').glob('page-*.xml'))[0]).getroot().attrib['ID']
84 conflict_views(name, output, page, 'cold', delete)
85 if delete:
86 # OneNote writes the deletion in the background; give it time before the re-read.
87 time.sleep(20)
88 runner.capture(notebook, output, collect_notebook=True, interaction=interaction)
89
90
91def sync(server, output):
92 from native_collaboration import reachable_page_text
93 from document_model import EXPORTER
94
95 if linux_vm.instance_path(server).exists():
96 raise ValueError('Choose a new Linux VM name; existing machines are not owned by this run.')
97 output = output.resolve()
98 output.mkdir(parents=True, exist_ok=False)
99 scripts = output / 'scripts'
100 scripts.mkdir()
101 for name in ('cold.ps1', 'collaborate.ps1', 'network.ps1', 'text.ps1', 'stress.ps1'):
102 shutil.copyfile(ROOT / 'tools/native' / name, scripts / name)
103 shutil.copyfile(Path(__file__), output / 'native_conflict.py')
104
105 def ssh(text, timeout=90):
106 result = linux_vm.run_ssh(server, text, timeout=timeout)
107 with (output / 'server.jsonl').open('a') as log:
108 log.write(json.dumps({'command': text, 'exit': result.returncode, 'stdout': result.stdout, 'stderr': result.stderr}) + '\n')
109 result.check_returncode()
110 return result.stdout
111
112 def server_state(label):
113 """Copies the server's notebook until it parses, keeping it as `label/notebook`."""
114 deadline = time.monotonic() + 120
115 while True:
116 target = output / label
117 shutil.rmtree(target, ignore_errors=True)
118 (target / 'notebook').mkdir(parents=True)
119 for name in ('synthetic.one', 'Open Notebook.onetoc2'):
120 with (target / 'notebook' / name).open('wb') as stream:
121 subprocess.run(linux_vm.ssh_argv(server, f"cat '/srv/agent/{SHARE}/{name}'"), stdout=stream, check=True, timeout=60)
122 result = subprocess.run([EXPORTER, target / 'notebook/synthetic.one', target / 'model'], capture_output=True, text=True)
123 if result.returncode == 0:
124 model = json.loads((target / 'model/document.json').read_text())
125 return reachable_page_text(model)
126 if time.monotonic() > deadline:
127 raise RuntimeError(f'{label}: {result.stderr}')
128 time.sleep(1)
129
130 linux_vm.create_instance(server)
131 try:
132 linux_vm.launch(server)
133 linux_vm.wait_instance(server, 600)
134 (output / 'linux.json').write_text(json.dumps(linux_vm.load_instance(server), indent=2))
135 ssh(f'mkdir /srv/agent/{SHARE}')
136 for name in ('synthetic.one', 'Open Notebook.onetoc2'):
137 with (FIXTURE / name).open('rb') as stream:
138 subprocess.run(linux_vm.ssh_argv(server, f"cat > '/srv/agent/{SHARE}/{name}'"), stdin=stream, check=True)
139 ssh(f'chmod u+w /srv/agent/{SHARE}/*')
140 with ExitStack() as stack:
141 labels = ['a', 'b']
142 for label in labels:
143 (output / label).mkdir()
144
145 def start(label):
146 manager = clone(output / label)
147 name = manager.__enter__()
148 return label, name, manager
149 with ThreadPoolExecutor(max_workers=2) as pool:
150 started = list(pool.map(start, labels))
151 for _, _, manager in started:
152 stack.push(manager)
153 clients = {}
154 for label, name, _ in started:
155 folder = output / label
156 for local in scripts.iterdir():
157 remote = 'cold-current.ps1' if local.name == 'cold.ps1' else local.name
158 result = windows.do_put(local, 'C:\\one-tests\\' + remote, name)
159 if result.get('error'): raise RuntimeError(result['error'])
160 command(name, 'mkdir C:\\one-tests\\runs\\capture', folder)
161 command(name, 'powershell -NoProfile -ExecutionPolicy Bypass -File C:\\one-tests\\network.ps1 -LabMac ' + vm.lab_mac(name), folder)
162 result = windows.do_spawn('cmd /c powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\collaborate.ps1 -Root C:\\one-tests\\runs\\capture -SharedPath \\\\192.168.77.1\\agent\\' + SHARE + ' -CloneHost ONE-' + name.upper() + ' > C:\\one-tests\\runs\\capture\\controller.log 2>&1', name)
163 if result.get('error'): raise RuntimeError(result['error'])
164 deadline = time.monotonic() + 180
165 while windows.do_cmd('if exist C:\\one-tests\\runs\\capture\\ready (echo ready)', target=name).get('stdout', '').find('ready') < 0:
166 if time.monotonic() > deadline: raise RuntimeError('The collaboration controller did not open the notebook')
167 time.sleep(1)
168 clients[label] = {'name': name, 'folder': folder, 'sequence': 0}
169 (output / 'clients.json').write_text(json.dumps({label: client['name'] for label, client in clients.items()}, indent=2))
170
171 def action(client, kind, **parameters):
172 client['sequence'] += 1
173 sequence = client['sequence']
174 local = client['folder'] / f'command-{sequence:04}.json'
175 local.write_text(json.dumps({'action': kind, **parameters}, ensure_ascii=False))
176 inbox = f'C:\\one-tests\\runs\\capture\\inbox\\{sequence:04}'
177 result = windows.do_put(local, inbox + '.tmp', client['name'])
178 if result.get('error'): raise RuntimeError(result['error'])
179 command(client['name'], f'move {inbox}.tmp {inbox}.json', client['folder'])
180 remote = f'C:\\one-tests\\runs\\capture\\outbox\\{sequence}'
181 deadline = time.monotonic() + 600
182 while True:
183 result = windows.do_cmd(f'if exist {remote}\\error (type {remote}\\error & exit /b 1) else (if exist {remote}\\done (echo complete))', target=client['name'])
184 if result.get('exit') != 0: raise RuntimeError(str(result))
185 if 'complete' in result.get('stdout', ''): break
186 if time.monotonic() > deadline: raise TimeoutError(f'{kind} did not complete')
187 time.sleep(.5)
188 if kind != 'snapshot':
189 return None
190 hierarchy = client['folder'] / f'hierarchy-{sequence:04}.xml'
191 windows.do_get(remote + '\\hierarchy.xml', hierarchy, client['name'])
192 pages = [node for node in ET.fromstring(hierarchy.read_text(encoding='utf-8-sig').strip()).iter() if node.tag.endswith('}Page')]
193 for i in range(len(pages)):
194 windows.do_get(remote + f'\\page-{i}.xml', client['folder'] / f'snapshot-{sequence:04}-{i}.xml', client['name'])
195 return pages
196
197 def settle(client, check, label):
198 deadline = time.monotonic() + 300
199 while True:
200 action(client, 'sync')
201 state = server_state(label)
202 if check(state): return state
203 if time.monotonic() > deadline: raise TimeoutError(f'{label}: {state}')
204 time.sleep(2)
205
206 a, b = clients['a'], clients['b']
207 texts = lambda state: {text for values in state.values() for text in values}
208 server_state('initial')
209 vm.qmp(a['name'], 'set_link', {'name': 'lab', 'up': False})
210 try:
211 action(a, 'edit', expected=ORIGINAL, text='Client A conflicting edit.')
212 action(a, 'edit', expected=OTHER_OUTLINE, text='Client A disjoint edit.')
213 action(a, 'snapshot')
214 action(b, 'edit', expected=ORIGINAL, text='Client B conflicting edit.')
215 action(b, 'edit', expected=CELL, text='Client B disjoint cell.')
216 settle(b, lambda state: {'Client B conflicting edit.', 'Client B disjoint cell.'} <= texts(state), 'b-published')
217 finally:
218 vm.qmp(a['name'], 'set_link', {'name': 'lab', 'up': True})
219 state = settle(a, lambda state: len(state) > 1 and 'Client A disjoint edit.' in texts(state), 'conflict')
220 (output / 'conflict.json').write_text(json.dumps(state, indent=2, ensure_ascii=False))
221 page = action(a, 'snapshot')[0].attrib['ID']
222 conflict_views(a['name'], output / 'a', page, 'a')
223 action(b, 'sync')
224 time.sleep(5)
225 page_b = action(b, 'snapshot')[0].attrib['ID']
226 conflict_views(b['name'], output / 'b', page_b, 'b')
227 conflict_views(a['name'], output / 'a', page, 'a-again', delete=True)
228 state = settle(a, lambda state: len(state) == 1, 'deleted')
229 (output / 'deleted.json').write_text(json.dumps(state, indent=2, ensure_ascii=False))
230 action(b, 'sync')
231 time.sleep(5)
232 shot(b['name'], output / 'b', 'b-after-delete', runner.navigation_script(page_b) + 'Sleep(2000)\n')
233 for client in clients.values():
234 try:
235 action(client, 'close')
236 except Exception as error:
237 (client['folder'] / 'close-failure.txt').write_text(str(error))
238 server_state('final')
239 finally:
240 if linux_vm.running(server):
241 linux_vm.shutdown(server, 120)
242 linux_vm.delete_instance(server)
243 (output / 'linux-teardown.json').write_text(json.dumps({'absent': not linux_vm.instance_path(server).exists()}) + '\n')
244
245
246if __name__ == '__main__':
247 parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
248 commands = parser.add_subparsers(dest='mode', required=True)
249 lab = commands.add_parser('sync')
250 lab.add_argument('linux')
251 lab.add_argument('output', type=Path)
252 reopen = commands.add_parser('cold')
253 reopen.add_argument('notebook', type=Path)
254 reopen.add_argument('output', type=Path)
255 reopen.add_argument('--delete', action='store_true')
256 args = parser.parse_args()
257 if args.mode == 'sync':
258 sync(args.linux, args.output)
259 else:
260 cold(args.notebook, args.output, args.delete)
261 print('Captured', args.output)