| 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 |
| 5 | two paragraphs offline while client B edits one of them and a table cell online; A then |
| 6 | reconnects, which makes OneNote write a conflict page. The server file is kept before and |
| 7 | after, A and B are screenshotted with the information bar, the page list shown, the conflict |
| 8 | page 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 |
| 11 | a page that client B also moves, moves another page, and edits a page B deletes; A then |
| 12 | reconnects. 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 |
| 15 | it 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 |
| 18 | clone and screenshots the same views; `--delete` deletes the conflict page through the menu |
| 19 | and captures the notebook OneNote leaves. |
| 20 | """ |
| 21 | import argparse |
| 22 | from contextlib import ExitStack |
| 23 | from concurrent.futures import ThreadPoolExecutor |
| 24 | import base64 |
| 25 | import json |
| 26 | from pathlib import Path |
| 27 | import shutil |
| 28 | import subprocess |
| 29 | import sys |
| 30 | import time |
| 31 | import xml.etree.ElementTree as ET |
| 32 | |
| 33 | sys.path.insert(0, str(Path(__file__).resolve().parent)) |
| 34 | sys.path.insert(0, str(Path(__file__).resolve().parent / 'w7')) |
| 35 | import native_runner as runner |
| 36 | from native_runner import ROOT, clone, command, windows, vm |
| 37 | import linux_vm |
| 38 | |
| 39 | FIXTURE = ROOT / 'corpus/native-ink/cold-ui-ink/notebook' |
| 40 | SHARE = 'm6-conflict' |
| 41 | ORIGINAL = 'Fictitious: café, 東京, مرحبا' |
| 42 | OTHER_OUTLINE = 'Fictitious positioned outline.' |
| 43 | CELL = '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. |
| 47 | BAR = (332, 100) |
| 48 | FIRST_CONFLICT = (725, 142) |
| 49 | # Delete Conflict Page, first in the menu the bar opens on a conflict page. |
| 50 | DELETE = (420, 113) |
| 51 | |
| 52 | |
| 53 | def 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 | |
| 66 | def 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. |
| 72 | DIALOG = ('if WinExist("ahk_class #32770 ahk_exe ONENOTE.EXE") {\n' |
| 73 | ' FileAppend("dialog: " WinGetTitle() " | " WinGetText() "`n", "*", "UTF-8")\n' |
| 74 | '}\n') |
| 75 | |
| 76 | |
| 77 | def 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 | |
| 90 | def 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 | |
| 100 | class 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 | |
| 105 | def 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 | |
| 112 | def 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 | |
| 143 | PAGES = ['One', 'Two', 'Three', 'Four', 'Target'] |
| 144 | |
| 145 | |
| 146 | def 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 | |
| 151 | def 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 | |
| 191 | def 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 | |
| 204 | def 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 | |
| 215 | def 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 | |
| 345 | if __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) |