| 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 | `cold NOTEBOOK OUTPUT [--delete]` cold-opens a notebook holding a conflict page in a fresh |
| 11 | clone and screenshots the same views; `--delete` deletes the conflict page through the menu |
| 12 | and captures the notebook OneNote leaves. |
| 13 | """ |
| 14 | import argparse |
| 15 | from contextlib import ExitStack |
| 16 | from concurrent.futures import ThreadPoolExecutor |
| 17 | import base64 |
| 18 | import json |
| 19 | from pathlib import Path |
| 20 | import shutil |
| 21 | import subprocess |
| 22 | import sys |
| 23 | import time |
| 24 | import xml.etree.ElementTree as ET |
| 25 | |
| 26 | sys.path.insert(0, str(Path(__file__).resolve().parent)) |
| 27 | sys.path.insert(0, str(Path(__file__).resolve().parent / 'w7')) |
| 28 | import native_runner as runner |
| 29 | from native_runner import ROOT, clone, command, windows, vm |
| 30 | import linux_vm |
| 31 | |
| 32 | FIXTURE = ROOT / 'corpus/native-ink/cold-ui-ink/notebook' |
| 33 | SHARE = 'm6-conflict' |
| 34 | ORIGINAL = 'Fictitious: café, 東京, مرحبا' |
| 35 | OTHER_OUTLINE = 'Fictitious positioned outline.' |
| 36 | CELL = '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. |
| 40 | BAR = (332, 100) |
| 41 | FIRST_CONFLICT = (725, 142) |
| 42 | |
| 43 | |
| 44 | def 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 | |
| 57 | def 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. |
| 63 | DIALOG = ('if WinExist("ahk_class #32770 ahk_exe ONENOTE.EXE") {\n' |
| 64 | ' FileAppend("dialog: " WinGetTitle() " | " WinGetText() "`n", "*", "UTF-8")\n' |
| 65 | '}\n') |
| 66 | |
| 67 | |
| 68 | def 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 | |
| 81 | def 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 | |
| 91 | def 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 | |
| 246 | if __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) |