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`pages LINUX OUTPUT` runs the same two clients on page-list changes: client A, offline, moves
11a page that client B also moves, moves another page, and edits a page B deletes; A then
12reconnects. The server's page order and pages, with the recycle bin, are kept at each step.
13
14`restore LINUX OUTPUT` runs them on a page in the middle of the list: client A, offline, edits
15it and moves a page B leaves alone while B deletes it; A then reconnects.
16
17`cold NOTEBOOK OUTPUT [--delete]` cold-opens a notebook holding a conflict page in a fresh
18clone and screenshots the same views; `--delete` deletes the conflict page through the menu
19and captures the notebook OneNote leaves.
20"""
21import argparse
22from contextlib import ExitStack
23from concurrent.futures import ThreadPoolExecutor
24import base64
25import json
26from pathlib import Path
27import shutil
28import subprocess
29import sys
30import time
31import xml.etree.ElementTree as ET
32
33sys.path.insert(0, str(Path(__file__).resolve().parent))
34sys.path.insert(0, str(Path(__file__).resolve().parent / 'w7'))
35import native_runner as runner
36from native_runner import ROOT, clone, command, windows, vm
37import linux_vm
38
39FIXTURE = ROOT / 'corpus/native-ink/cold-ui-ink/notebook'
40SHARE = 'm6-conflict'
41ORIGINAL = 'Fictitious: café, 東京, مرحبا'
42OTHER_OUTLINE = 'Fictitious positioned outline.'
43CELL = 'Left cell'
44
45# Coordinates on OneNote 2010 maximized at 800x600, from the stage-5, m8 and sync-01 captures:
46# the information bar's text, and the first conflict page listed under its page.
47BAR = (332, 100)
48FIRST_CONFLICT = (725, 142)
49# Delete Conflict Page, first in the menu the bar opens on a conflict page.
50DELETE = (420, 113)
51
52
53def shot(name, output, label, script):
54 """Runs an AutoHotkey step on the clone, keeping its screenshot as `label.png`."""
55 (output / f'{label}.ahk').write_text(script)
56 result = windows.do_exec(script, target=name, timeout_ms=120000, shot_delay_ms=1500)
57 screenshot = result.pop('png_b64', None)
58 if screenshot:
59 (output / f'{label}.png').write_bytes(base64.b64decode(screenshot))
60 (output / f'{label}.json').write_text(json.dumps(result, indent=2))
61 if result.get('error') or result.get('exit') != 0:
62 raise RuntimeError(f'{label}: {result}')
63 return result
64
65
66def click(x, y):
67 return ('WinActivate("ahk_exe ONENOTE.EXE")\nCoordMode("Mouse", "Screen")\n'
68 f'Click({x}, {y})\nSleep(1200)\n')
69
70
71# Reports any dialog OneNote raised, so a confirmation shows in the step's record.
72DIALOG = ('if WinExist("ahk_class #32770 ahk_exe ONENOTE.EXE") {\n'
73 ' FileAppend("dialog: " WinGetTitle() " | " WinGetText() "`n", "*", "UTF-8")\n'
74 '}\n')
75
76
77def conflict_views(name, output, page, prefix, delete=False):
78 """Screenshots a page with a conflict page: the bar, the list shown, the conflict page and
79 its menu; `delete` then chooses Delete Conflict Page."""
80 shot(name, output, f'{prefix}-main', runner.navigation_script(page) + 'Sleep(2000)\n')
81 shot(name, output, f'{prefix}-shown', click(*BAR))
82 shot(name, output, f'{prefix}-conflict', click(*FIRST_CONFLICT))
83 shot(name, output, f'{prefix}-menu', click(*BAR))
84 if delete:
85 shot(name, output, f'{prefix}-delete', click(*DELETE) + DIALOG)
86 else:
87 shot(name, output, f'{prefix}-closed', 'WinActivate("ahk_exe ONENOTE.EXE")\nSend("{Esc}")\nSleep(800)\n')
88
89
90def cold(notebook, output, delete):
91 def interaction(name, output):
92 page = ET.parse(sorted((output / 'read').glob('page-*.xml'))[0]).getroot().attrib['ID']
93 conflict_views(name, output, page, 'cold', delete)
94 if delete:
95 # OneNote writes the deletion in the background; give it time before the re-read.
96 time.sleep(20)
97 runner.capture(notebook, output, collect_notebook=True, interaction=interaction)
98
99
100class Lab:
101 """Two OneNote clients on one Samba notebook: `action` runs a controller command,
102 `state` copies the share and exports its section, `settle` syncs until a check holds."""
103
104
105def page_list(model):
106 """The section's pages in order, each as its sorted texts."""
107 from document_model import ordered_pages, walk
108 return [sorted(node['kind']['text'] for _, node in walk(page, oid) if node['kind']['type'] == 'RichText')
109 for _, _, page, oid in ordered_pages(model)]
110
111
112def edits(lab):
113 from native_collaboration import reachable_page_text
114 a, b, output, action, settle = lab.a, lab.b, lab.output, lab.action, lab.settle
115 state = lambda label: reachable_page_text(lab.state(label))
116 texts = lambda state: {text for values in state.values() for text in values}
117 state('initial')
118 vm.qmp(a['name'], 'set_link', {'name': 'lab', 'up': False})
119 try:
120 action(a, 'edit', expected=ORIGINAL, text='Client A conflicting edit.')
121 action(a, 'edit', expected=OTHER_OUTLINE, text='Client A disjoint edit.')
122 action(a, 'snapshot')
123 action(b, 'edit', expected=ORIGINAL, text='Client B conflicting edit.')
124 action(b, 'edit', expected=CELL, text='Client B disjoint cell.')
125 settle(b, lambda label: {'Client B conflicting edit.', 'Client B disjoint cell.'} <= texts(state(label)), 'b-published')
126 finally:
127 vm.qmp(a['name'], 'set_link', {'name': 'lab', 'up': True})
128 settle(a, lambda label: len(found := state(label)) > 1 and 'Client A disjoint edit.' in texts(found), 'conflict')
129 (output / 'conflict.json').write_text(json.dumps(state('conflict'), indent=2, ensure_ascii=False))
130 page = action(a, 'snapshot')[0].attrib['ID']
131 conflict_views(a['name'], output / 'a', page, 'a')
132 action(b, 'sync')
133 time.sleep(5)
134 page_b = action(b, 'snapshot')[0].attrib['ID']
135 conflict_views(b['name'], output / 'b', page_b, 'b')
136 conflict_views(a['name'], output / 'a', page, 'a-again', delete=True)
137 settle(a, lambda label: len(state(label)) == 1, 'deleted')
138 action(b, 'sync')
139 time.sleep(5)
140 shot(b['name'], output / 'b', 'b-after-delete', runner.navigation_script(page_b) + 'Sleep(2000)\n')
141
142
143PAGES = ['One', 'Two', 'Three', 'Four', 'Target']
144
145
146def titled(model, titles):
147 """The section's pages in order, each named by the title among `titles` it holds."""
148 return [next((text for text in texts if text in titles), '') for texts in page_list(model)]
149
150
151def offline(lab, titles, a_actions, b_actions):
152 """Creates the titled pages; A runs its actions offline while B runs and publishes its
153 own; A then reconnects. Keeps the merged order and pages, and what each client lists."""
154 a, b, output, action = lab.a, lab.b, lab.output, lab.action
155 action(a, 'pages', titles=titles)
156 lab.settle(a, lambda label: 'Body Target.' in str(page_list(lab.state(label))), 'initial')
157 deadline = time.monotonic() + 300
158 while not any(node.get('name') == 'Target' for node in action(b, 'snapshot')):
159 action(b, 'sync')
160 if time.monotonic() > deadline: raise TimeoutError('B never listed the new pages')
161 vm.qmp(a['name'], 'set_link', {'name': 'lab', 'up': False})
162 try:
163 for kind, parameters in a_actions:
164 action(a, kind, **parameters)
165 action(a, 'snapshot')
166 for kind, parameters in b_actions:
167 action(b, kind, **parameters)
168 action(b, 'snapshot')
169 lab.settle(b, lambda label: 'Body Target.' not in str(page_list(lab.state(label))), 'b-published')
170 finally:
171 vm.qmp(a['name'], 'set_link', {'name': 'lab', 'up': True})
172 # A's changes reach the server when its sync writes; wait until the file stops changing.
173 previous, steady = None, 0
174 deadline = time.monotonic() + 300
175 while steady < 3 and time.monotonic() < deadline:
176 action(a, 'sync')
177 time.sleep(10)
178 current = page_list(lab.state('merged'))
179 steady = steady + 1 if current == previous else 0
180 previous = current
181 (output / 'merged.json').write_text(json.dumps({'order': titled(lab.state('merged'), titles), 'pages': previous}, indent=2, ensure_ascii=False))
182 for client, label in ((a, 'a'), (b, 'b')):
183 action(client, 'sync')
184 time.sleep(5)
185 listed = action(client, 'snapshot')
186 (output / label / 'listed.json').write_text(json.dumps([node.get('name') for node in listed], ensure_ascii=False))
187 if listed:
188 shot(client['name'], output / label, f'{label}-merged', runner.navigation_script(listed[0].get('ID')) + 'Sleep(2000)\n')
189
190
191def pages(lab):
192 """Competing page moves, and an offline edit of a page the other client deletes."""
193 offline(lab, PAGES, [
194 ('move', {'page': 'Four', 'before': 'One'}),
195 ('move', {'page': 'Two', 'before': None}),
196 ('edit', {'expected': 'Body Target.', 'text': 'Body Target edited offline.'}),
197 ], [
198 ('move', {'page': 'Four', 'before': None}),
199 ('move', {'page': 'Three', 'before': 'One'}),
200 ('delete-page', {'page': 'Target'}),
201 ])
202
203
204def restore(lab):
205 """An offline edit of a page in the middle of the list the other client deletes, with an
206 offline move of a page the other client leaves alone."""
207 offline(lab, ['One', 'Two', 'Target', 'Three', 'Four'], [
208 ('edit', {'expected': 'Body Target.', 'text': 'Body Target edited offline.'}),
209 ('move', {'page': 'Four', 'before': 'One'}),
210 ], [
211 ('delete-page', {'page': 'Target'}),
212 ])
213
214
215def sync(server, output, scenario):
216 from document_model import EXPORTER
217
218 if linux_vm.instance_path(server).exists():
219 raise ValueError('Choose a new Linux VM name; existing machines are not owned by this run.')
220 output = output.resolve()
221 output.mkdir(parents=True, exist_ok=False)
222 scripts = output / 'scripts'
223 scripts.mkdir()
224 for name in ('cold.ps1', 'collaborate.ps1', 'network.ps1', 'text.ps1', 'stress.ps1'):
225 shutil.copyfile(ROOT / 'tools/native' / name, scripts / name)
226 shutil.copyfile(Path(__file__), output / 'native_conflict.py')
227
228 def ssh(text, timeout=90):
229 result = linux_vm.run_ssh(server, text, timeout=timeout)
230 with (output / 'server.jsonl').open('a') as log:
231 log.write(json.dumps({'command': text, 'exit': result.returncode, 'stdout': result.stdout, 'stderr': result.stderr}) + '\n')
232 result.check_returncode()
233 return result.stdout
234
235 def server_state(label):
236 """Copies the server's notebook, recycle bin included, until its section parses,
237 keeping it as `label/notebook`; returns the section's exported model."""
238 deadline = time.monotonic() + 120
239 while True:
240 target = output / label
241 shutil.rmtree(target, ignore_errors=True)
242 (target / 'notebook').mkdir(parents=True)
243 archive = subprocess.run(linux_vm.ssh_argv(server, f"tar cf - -C '/srv/agent/{SHARE}' ."), capture_output=True, check=True, timeout=60).stdout
244 subprocess.run(['tar', 'xf', '-', '-C', target / 'notebook'], input=archive, check=True)
245 result = subprocess.run([EXPORTER, target / 'notebook/synthetic.one', target / 'model'], capture_output=True, text=True)
246 if result.returncode == 0:
247 return json.loads((target / 'model/document.json').read_text())
248 if time.monotonic() > deadline:
249 raise RuntimeError(f'{label}: {result.stderr}')
250 time.sleep(1)
251
252 linux_vm.create_instance(server)
253 try:
254 linux_vm.launch(server)
255 linux_vm.wait_instance(server, 600)
256 (output / 'linux.json').write_text(json.dumps(linux_vm.load_instance(server), indent=2))
257 ssh(f'mkdir /srv/agent/{SHARE}')
258 for name in ('synthetic.one', 'Open Notebook.onetoc2'):
259 with (FIXTURE / name).open('rb') as stream:
260 subprocess.run(linux_vm.ssh_argv(server, f"cat > '/srv/agent/{SHARE}/{name}'"), stdin=stream, check=True)
261 ssh(f'chmod u+w /srv/agent/{SHARE}/*')
262 with ExitStack() as stack:
263 labels = ['a', 'b']
264 for label in labels:
265 (output / label).mkdir()
266
267 def start(label):
268 manager = clone(output / label)
269 name = manager.__enter__()
270 return label, name, manager
271 with ThreadPoolExecutor(max_workers=2) as pool:
272 started = list(pool.map(start, labels))
273 for _, _, manager in started:
274 stack.push(manager)
275 clients = {}
276 for label, name, _ in started:
277 folder = output / label
278 for local in scripts.iterdir():
279 remote = 'cold-current.ps1' if local.name == 'cold.ps1' else local.name
280 result = windows.do_put(local, 'C:\\one-tests\\' + remote, name)
281 if result.get('error'): raise RuntimeError(result['error'])
282 command(name, 'mkdir C:\\one-tests\\runs\\capture', folder)
283 command(name, 'powershell -NoProfile -ExecutionPolicy Bypass -File C:\\one-tests\\network.ps1 -LabMac ' + vm.lab_mac(name), folder)
284 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)
285 if result.get('error'): raise RuntimeError(result['error'])
286 deadline = time.monotonic() + 180
287 while windows.do_cmd('if exist C:\\one-tests\\runs\\capture\\ready (echo ready)', target=name).get('stdout', '').find('ready') < 0:
288 if time.monotonic() > deadline: raise RuntimeError('The collaboration controller did not open the notebook')
289 time.sleep(1)
290 clients[label] = {'name': name, 'folder': folder, 'sequence': 0}
291 (output / 'clients.json').write_text(json.dumps({label: client['name'] for label, client in clients.items()}, indent=2))
292
293 def action(client, kind, **parameters):
294 client['sequence'] += 1
295 sequence = client['sequence']
296 local = client['folder'] / f'command-{sequence:04}.json'
297 local.write_text(json.dumps({'action': kind, **parameters}, ensure_ascii=False))
298 inbox = f'C:\\one-tests\\runs\\capture\\inbox\\{sequence:04}'
299 result = windows.do_put(local, inbox + '.tmp', client['name'])
300 if result.get('error'): raise RuntimeError(result['error'])
301 command(client['name'], f'move {inbox}.tmp {inbox}.json', client['folder'])
302 remote = f'C:\\one-tests\\runs\\capture\\outbox\\{sequence}'
303 deadline = time.monotonic() + 600
304 while True:
305 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'])
306 if result.get('exit') != 0: raise RuntimeError(str(result))
307 if 'complete' in result.get('stdout', ''): break
308 if time.monotonic() > deadline: raise TimeoutError(f'{kind} did not complete')
309 time.sleep(.5)
310 if kind != 'snapshot':
311 return None
312 hierarchy = client['folder'] / f'hierarchy-{sequence:04}.xml'
313 windows.do_get(remote + '\\hierarchy.xml', hierarchy, client['name'])
314 pages = [node for node in ET.fromstring(hierarchy.read_text(encoding='utf-8-sig').strip()).iter() if node.tag.endswith('}Page')]
315 for i in range(len(pages)):
316 windows.do_get(remote + f'\\page-{i}.xml', client['folder'] / f'snapshot-{sequence:04}-{i}.xml', client['name'])
317 return pages
318
319 def settle(client, check, label):
320 deadline = time.monotonic() + 300
321 while True:
322 action(client, 'sync')
323 if check(label): return
324 if time.monotonic() > deadline: raise TimeoutError(label)
325 time.sleep(2)
326
327 lab = Lab()
328 lab.a, lab.b, lab.output = clients['a'], clients['b'], output
329 lab.action, lab.state, lab.settle = action, server_state, settle
330 scenario(lab)
331
332 for client in clients.values():
333 try:
334 action(client, 'close')
335 except Exception as error:
336 (client['folder'] / 'close-failure.txt').write_text(str(error))
337 server_state('final')
338 finally:
339 if linux_vm.running(server):
340 linux_vm.shutdown(server, 120)
341 linux_vm.delete_instance(server)
342 (output / 'linux-teardown.json').write_text(json.dumps({'absent': not linux_vm.instance_path(server).exists()}) + '\n')
343
344
345if __name__ == '__main__':
346 parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
347 commands = parser.add_subparsers(dest='mode', required=True)
348 scenarios = {'sync': edits, 'pages': pages, 'restore': restore}
349 for mode in scenarios:
350 lab = commands.add_parser(mode)
351 lab.add_argument('linux')
352 lab.add_argument('output', type=Path)
353 reopen = commands.add_parser('cold')
354 reopen.add_argument('notebook', type=Path)
355 reopen.add_argument('output', type=Path)
356 reopen.add_argument('--delete', action='store_true')
357 args = parser.parse_args()
358 if args.mode in scenarios:
359 sync(args.linux, args.output, scenarios[args.mode])
360 else:
361 cold(args.notebook, args.output, args.delete)
362 print('Captured', args.output)