@logicseek/hello-cc 1.1.0-rc.1 → 1.1.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/CHANGELOG.md +190 -108
  2. package/README.md +1 -10
  3. package/README.zh-CN.md +1 -8
  4. package/docs/codex-account.zh-CN.md +38 -0
  5. package/docs/codex-protocol.md +59 -0
  6. package/docs/commands.md +13 -28
  7. package/docs/commands.zh-CN.md +11 -20
  8. package/docs/dsh.md +5 -3
  9. package/docs/dsh.zh-CN.md +6 -4
  10. package/docs/macos-process-identity.md +15 -0
  11. package/docs/native.md +95 -4
  12. package/docs/native.zh-CN.md +70 -1
  13. package/docs/web-handoff.zh-CN.md +107 -5
  14. package/lib/cli/app.mjs +9 -6
  15. package/lib/cli/commands/coordination.mjs +7 -2
  16. package/lib/cli/commands/gc.mjs +14 -12
  17. package/lib/cli/commands/hook.mjs +16 -3
  18. package/lib/cli/commands/install.mjs +37 -9
  19. package/lib/cli/commands/lock.mjs +3 -1
  20. package/lib/cli/commands/misc.mjs +105 -13
  21. package/lib/cli/commands/native.mjs +3 -44
  22. package/lib/cli/commands/peer.mjs +3 -0
  23. package/lib/cli/commands/run.mjs +13 -6
  24. package/lib/cli/commands/tmux.mjs +1 -2
  25. package/lib/cli-runtime.mjs +19 -2
  26. package/lib/core/coordination/gc-plan.mjs +5 -0
  27. package/lib/core/coordination/locks.mjs +3 -1
  28. package/lib/core/peers/evidence.mjs +21 -1
  29. package/lib/core/peers/tmux-safety.mjs +4 -0
  30. package/lib/core/sessions/hook-root-identity.mjs +47 -0
  31. package/lib/core/sessions/launch.mjs +7 -1
  32. package/lib/db/connection.mjs +67 -21
  33. package/lib/diagnostics/codex-protocol.mjs +105 -0
  34. package/lib/guidance.mjs +20 -36
  35. package/lib/integrations/codex-account.mjs +143 -0
  36. package/lib/integrations/dsh-collaboration.mjs +3 -4
  37. package/lib/integrations/dsh.mjs +4 -2
  38. package/lib/integrations/native/claude.mjs +24 -6
  39. package/lib/integrations/native/codex.mjs +38 -7
  40. package/lib/integrations/native/dsh-acp.mjs +67 -24
  41. package/lib/integrations/native/index.mjs +4 -1
  42. package/lib/integrations/native/jsonrpc.mjs +37 -5
  43. package/lib/integrations/native/telemetry.mjs +30 -0
  44. package/lib/process/identity.mjs +67 -28
  45. package/lib/process/pinned-cwd.mjs +302 -0
  46. package/lib/process/pinned-guidance.mjs +138 -0
  47. package/lib/process/pinned-project-state.mjs +105 -0
  48. package/lib/process/selected-cwd-identity.mjs +95 -0
  49. package/lib/runtime/buffer-evidence.mjs +7 -1
  50. package/lib/runtime/client.mjs +18 -1
  51. package/lib/runtime/native/client.mjs +4 -1
  52. package/lib/runtime/native/history.mjs +140 -0
  53. package/lib/runtime/native/launcher.mjs +106 -0
  54. package/lib/runtime/native/service.mjs +160 -27
  55. package/lib/runtime/native/store.mjs +51 -21
  56. package/lib/runtime/project-path.mjs +24 -5
  57. package/lib/runtime/projects.mjs +3 -0
  58. package/lib/runtime/state.mjs +61 -26
  59. package/lib/shared/file-lock.mjs +62 -17
  60. package/lib/shared/redact.mjs +2 -1
  61. package/lib/shared/socket-lock-worker.mjs +15 -6
  62. package/lib/web/agent-defaults.mjs +110 -0
  63. package/lib/web/auto-attach.mjs +3 -2
  64. package/lib/web/browser/contracts.d.ts +10 -0
  65. package/lib/web/browser/core.mjs +1997 -0
  66. package/lib/web/browser/project-data.mjs +158 -0
  67. package/lib/web/browser/session-sync.mjs +82 -0
  68. package/lib/web/browser-assets.mjs +19 -0
  69. package/lib/web/buffer-gc-runtime.mjs +2 -2
  70. package/lib/web/codex-app-server.mjs +104 -33
  71. package/lib/web/codex-sessions.mjs +60 -11
  72. package/lib/web/context-files.mjs +82 -0
  73. package/lib/web/cookie-auth.mjs +51 -3
  74. package/lib/web/external-sessions.mjs +22 -4
  75. package/lib/web/http-routes.mjs +236 -23
  76. package/lib/web/http.mjs +50 -4
  77. package/lib/web/liveness-reaper.mjs +2 -2
  78. package/lib/web/native-sessions.mjs +322 -13
  79. package/lib/web/project-contexts.mjs +235 -42
  80. package/lib/web/project-file-write.mjs +249 -0
  81. package/lib/web/project-files.mjs +268 -0
  82. package/lib/web/pty-sessions.mjs +11 -3
  83. package/lib/web/runtime-main.mjs +134 -82
  84. package/lib/web/session-serialize.mjs +87 -8
  85. package/lib/web/session-state-sync.mjs +98 -0
  86. package/lib/web/shutdown-diagnostics.mjs +38 -0
  87. package/lib/web/shutdown-response.mjs +21 -0
  88. package/lib/web/shutdown-state.mjs +91 -0
  89. package/lib/web/startup.mjs +76 -27
  90. package/lib/web/tmux-client-snapshot.mjs +85 -0
  91. package/lib/web/tmux-sessions.mjs +71 -7
  92. package/lib/web/tmux-stream.mjs +36 -8
  93. package/lib/web/ui-agent-defaults.mjs +149 -0
  94. package/lib/web/ui-agent-start.mjs +330 -0
  95. package/lib/web/ui-codex-account.mjs +81 -0
  96. package/lib/web/ui-codex.mjs +81 -73
  97. package/lib/web/ui-command-palette.mjs +21 -5
  98. package/lib/web/ui-file-writes.mjs +220 -0
  99. package/lib/web/ui-files.mjs +231 -0
  100. package/lib/web/ui-handoff.mjs +10 -2
  101. package/lib/web/ui-history.mjs +175 -105
  102. package/lib/web/ui-i18n.mjs +280 -8
  103. package/lib/web/ui-interactions.mjs +6 -4
  104. package/lib/web/ui-native-timeline.mjs +326 -0
  105. package/lib/web/ui-native.mjs +93 -49
  106. package/lib/web/ui-review.mjs +5 -3
  107. package/lib/web/ui-safe-markdown.mjs +79 -0
  108. package/lib/web/ui-session-tools.mjs +224 -0
  109. package/lib/web/ui-template.mjs +27 -1967
  110. package/lib/web/ui-terminal-find.mjs +5 -3
  111. package/lib/web/ui-workbench.mjs +5 -3
  112. package/lib/web/ui-workspace.mjs +3 -0
  113. package/package.json +8 -2
  114. package/scripts/benchmark-session-sync.mjs +53 -0
  115. package/scripts/codex-protocol.mjs +88 -0
  116. package/scripts/dsh-approval-layers-acceptance.mjs +172 -0
  117. package/scripts/dsh-package-provenance.mjs +118 -0
  118. package/scripts/native-bounded-live-acceptance.mjs +193 -0
  119. package/scripts/native-fixture-root.mjs +49 -0
  120. package/scripts/native-installed-acceptance.mjs +204 -6
  121. package/scripts/native-stability-checks.mjs +40 -0
  122. package/scripts/regression-cookie-expiry.mjs +70 -0
  123. package/scripts/regression-http-transport.mjs +9 -0
  124. package/scripts/regression-terminal-marker.mjs +95 -0
  125. package/scripts/regression.mjs +637 -186
  126. package/scripts/shim-reentry-probe.mjs +105 -0
  127. package/scripts/web-acceptance-diagnostics.mjs +90 -0
  128. package/scripts/web-codex-account-acceptance.mjs +178 -0
  129. package/scripts/web-file-preview-checks.mjs +302 -0
  130. package/scripts/web-mcp-form-acceptance.mjs +22 -7
  131. package/scripts/web-mcp-form-live-acceptance.mjs +1 -1
  132. package/scripts/web-native-handoff-checks.mjs +128 -0
  133. package/scripts/web-native-interaction-acceptance.mjs +152 -18
  134. package/scripts/web-native-interaction-diagnostics.mjs +80 -0
  135. package/scripts/web-native-model-acceptance.mjs +22 -7
  136. package/scripts/web-session-tools-checks.mjs +112 -0
  137. package/scripts/web-workbench-acceptance.mjs +435 -0
  138. package/scripts/web-workbench-installed-acceptance.mjs +122 -0
@@ -0,0 +1,302 @@
1
+ // Reusable browser checks for normal, test-owned project artifacts.
2
+ // Shares the acceptance runtime; never starts an Agent or calls a model.
3
+ import assert from 'node:assert/strict';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { createHash } from 'node:crypto';
7
+ import { deflateSync } from 'node:zlib';
8
+
9
+ export const FILE_PREVIEW_CHECKS = Object.freeze([
10
+ 'standalone', 'html', 'markdown', 'text', 'image', 'pdf', 'formats',
11
+ 'bounded-text', 'mobile', 'late-file', 'close-pending', 'project-switch', 'readonly'
12
+ ]);
13
+
14
+ const sha256 = content => createHash('sha256').update(content).digest('hex');
15
+ function pngFixture() {
16
+ const crc = bytes => {
17
+ let value = -1;
18
+ for (const byte of bytes) {
19
+ value ^= byte;
20
+ for (let bit = 0; bit < 8; bit++) value = (value >>> 1) ^ ((value & 1) ? 0xedb88320 : 0);
21
+ }
22
+ return (value ^ -1) >>> 0;
23
+ };
24
+ const chunk = (name, data) => {
25
+ const type = Buffer.from(name), length = Buffer.alloc(4), checksum = Buffer.alloc(4);
26
+ length.writeUInt32BE(data.length); checksum.writeUInt32BE(crc(Buffer.concat([type, data])));
27
+ return Buffer.concat([length, type, data, checksum]);
28
+ };
29
+ const width = 640, height = 320, rows = Buffer.alloc((width * 3 + 1) * height);
30
+ for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
31
+ const offset = y * (width * 3 + 1) + 1 + x * 3;
32
+ let color = [244, 249, 247];
33
+ if (x > 55 && x < 590 && (y === 272 || y === 273)) color = [182, 204, 196];
34
+ for (let bar = 0; bar < 5; bar++) {
35
+ if (x > 75 + 100 * bar && x < 138 + 100 * bar && y > 215 - bar * 29 && y < 272) color = [19 + bar * 7, 125 + bar * 10, 107 + bar * 6];
36
+ }
37
+ for (let channel = 0; channel < 3; channel++) rows[offset + channel] = color[channel];
38
+ }
39
+ const header = Buffer.alloc(13);
40
+ header.writeUInt32BE(width, 0); header.writeUInt32BE(height, 4); header[8] = 8; header[9] = 2;
41
+ return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', header), chunk('IDAT', deflateSync(rows)), chunk('IEND', Buffer.alloc(0))]);
42
+ }
43
+
44
+ function pdfFixture() {
45
+ const stream = 'BT /F1 25 Tf 54 738 Td (Project report) Tj 0 -45 Td /F1 13 Tf (File preview acceptance fixture) Tj 0 -30 Td (All five sample milestones are complete.) Tj ET';
46
+ const objects = [
47
+ '<< /Type /Catalog /Pages 2 0 R >>', '<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
48
+ '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>',
49
+ '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
50
+ '<< /Length ' + Buffer.byteLength(stream) + ' >>\nstream\n' + stream + '\nendstream'
51
+ ];
52
+ let text = '%PDF-1.4\n';
53
+ const offsets = [];
54
+ for (const [index, body] of objects.entries()) {
55
+ offsets.push(Buffer.byteLength(text)); text += (index + 1) + ' 0 obj\n' + body + '\nendobj\n';
56
+ }
57
+ const xref = Buffer.byteLength(text);
58
+ text += 'xref\n0 6\n0000000000 65535 f \n' + offsets.map(offset => String(offset).padStart(10, '0') + ' 00000 n \n').join('')
59
+ + 'trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n' + xref + '\n%%EOF\n';
60
+ return Buffer.from(text);
61
+ }
62
+
63
+ function fixtures(sandbox) {
64
+ const first = path.join(sandbox, 'preview project with spaces'), second = path.join(sandbox, 'second preview project');
65
+ const files = new Map([
66
+ [path.join(first, 'README.md'), '# Project handoff\n\nA **reviewable result** from the workspace.\n\n- Inspect the report\n- Review the chart\n- Read the source\n\n```js\nconst status = "complete";\n```\n'],
67
+ [path.join(first, 'src/main.mjs'), 'export const greeting = "Hello from the project";\nexport const example = "<sample>";\n'],
68
+ [path.join(first, 'artifacts/report.html'), '<!doctype html><html><head><title>Milestone report</title><style>body{font:16px system-ui;margin:0;padding:40px;background:#f5faf7;color:#174837}h1{font-size:32px}section{padding:22px;background:white;border:1px solid #cce2d5;border-radius:12px;margin-top:28px}strong{font-size:40px;color:#12876b}</style></head><body><h1>Milestone report</h1><p>Generated locally</p><section><strong>5 / 5</strong><h2>Milestones completed</h2><p>Inspect this saved artifact without starting an Agent.</p></section></body></html>'],
69
+ [path.join(first, 'artifacts/chart.png'), pngFixture()],
70
+ [path.join(first, 'artifacts/report.pdf'), pdfFixture()],
71
+ [path.join(first, 'artifacts/sample.svg'), '<svg xmlns="http://www.w3.org/2000/svg"><text x="20" y="20">Source only</text></svg>'],
72
+ [path.join(first, 'artifacts/data.bin'), Buffer.from([0, 1, 2, 255, 0, 4])],
73
+ [path.join(first, 'large.txt'), 'Large text fixture\n' + 'example line\n'.repeat(100000)],
74
+ [path.join(first, 'dist/index.html'), '<h1>Build output is browsable</h1>'],
75
+ [path.join(second, 'SECOND.md'), '# Second project\n\nThe selected project owns this preview.\n']
76
+ ]);
77
+ for (const [filename, content] of files) {
78
+ fs.mkdirSync(path.dirname(filename), { recursive: true, mode: 0o700 }); fs.writeFileSync(filename, content);
79
+ }
80
+ return { first, second, files };
81
+ }
82
+
83
+ function deferred() {
84
+ let resolve, reject;
85
+ const promise = new Promise((accept, decline) => { resolve = accept; reject = decline; });
86
+ return { promise, resolve, reject };
87
+ }
88
+
89
+ async function bounded(promise, label) {
90
+ let timer;
91
+ try {
92
+ return await Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Timed out: ' + label)), 15000); })]);
93
+ } finally { clearTimeout(timer); }
94
+ }
95
+
96
+ // Delay delivery of an actual response. File contents and server behavior are
97
+ // unchanged; the only controlled variable is when the browser receives it.
98
+ async function delayedPreview(page, project, filename, whileHeld) {
99
+ const arrived = deferred(), release = deferred(), settled = deferred(), completed = deferred();
100
+ let heldRequest, routeError;
101
+ const matches = url => url.pathname === '/api/files/preview' && url.searchParams.get('path') === filename && url.searchParams.get('root') === project;
102
+ const finished = request => { if (request === heldRequest) completed.resolve(); };
103
+ const handler = async route => {
104
+ heldRequest = route.request();
105
+ try {
106
+ const response = await route.fetch();
107
+ assert.equal(response.status(), 200, 'The delayed preview must be a real successful file read');
108
+ arrived.resolve(); await release.promise; await route.fulfill({ response });
109
+ } catch (error) { routeError = error; arrived.reject(error); }
110
+ finally { settled.resolve(); }
111
+ };
112
+ page.on('requestfinished', finished); page.on('requestfailed', finished);
113
+ await page.route(matches, handler);
114
+ try {
115
+ await page.evaluate(filename => window.hccFiles.open(filename), filename);
116
+ await bounded(arrived.promise, 'preview response fetched');
117
+ await whileHeld(); release.resolve();
118
+ await bounded(settled.promise, 'preview response released');
119
+ await bounded(completed.promise, 'preview request settled in browser');
120
+ if (routeError && heldRequest.failure()?.errorText !== 'net::ERR_ABORTED') throw routeError;
121
+ // Wait for the browser's next rendered state, not an arbitrary sleep.
122
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
123
+ return heldRequest.failure() ? 'cancelled-on-project-change' : 'delivered';
124
+ } finally {
125
+ release.resolve(); await page.unroute(matches, handler);
126
+ page.off('requestfinished', finished); page.off('requestfailed', finished);
127
+ }
128
+ }
129
+
130
+ export async function runFilePreviewChecks({ browser, base, token, sandbox, registerPage, adapters, check, shot, watch, browserInstrumentation, evidence }) {
131
+ const { first, second, files } = fixtures(sandbox);
132
+ const hashes = () => Object.fromEntries([...files.keys()].map(filename => [path.relative(sandbox, filename), sha256(fs.readFileSync(filename))]));
133
+ const before = hashes(), providers = [...adapters.keys()], sends = providers.map(provider => adapters.get(provider).sent || 0);
134
+ const receipt = evidence.filePreview = {
135
+ schemaVersion: 1, checks: [], viewports: [{ width: 1440, height: 1000 }, { width: 390, height: 844 }],
136
+ modelCalls: false, fileHashes: before, fileRequests: [], writeRequests: [],
137
+ pdfScope: 'Typed PDF Blob, iframe handoff and downloaded bytes; browser PDF viewer internals and OS rendering are not asserted'
138
+ };
139
+ const pass = (id, label) => { receipt.checks.push(id); check('file preview: ' + label); };
140
+ const previousProject = await registerPage.evaluate(() => window.hccHandoff.projectRoot);
141
+ for (const project of [first, second]) {
142
+ await registerPage.locator('#openProjectDialog').click();
143
+ await registerPage.locator('#projectPath').fill(project);
144
+ await registerPage.locator('#addProjectBtn').click();
145
+ await registerPage.waitForFunction(project => window.hccHandoff.projectRoot === project &&
146
+ document.getElementById('projectDialog').hidden, project);
147
+ }
148
+ await registerPage.locator('#projectSelect').selectOption(previousProject);
149
+ await registerPage.waitForFunction(project => window.hccHandoff.projectRoot === project &&
150
+ window.hccHandoff.actionToken && /(已连接|Connected)$/.test(document.getElementById('handoffConnection').textContent), previousProject);
151
+ const context = await browser.newContext({ viewport: receipt.viewports[0], acceptDownloads: true });
152
+ await context.addInitScript(browserInstrumentation);
153
+ // Observe the normal browser Blob handoff. Fetching a blob: URL is outside
154
+ // the preview flow and intentionally unavailable under the page's CSP.
155
+ await context.addInitScript(() => {
156
+ const create = URL.createObjectURL;
157
+ window.__hccPreviewBlobs = new Map();
158
+ URL.createObjectURL = function (blob) {
159
+ const url = create.call(this, blob);
160
+ window.__hccPreviewBlobs.set(url, { type: blob.type, size: blob.size });
161
+ return url;
162
+ };
163
+ });
164
+ const page = await context.newPage(); watch(page);
165
+ const open = async () => {
166
+ await page.locator('#filesBtn').click(); await page.locator('#filesDialog').waitFor({ state: 'visible' });
167
+ await page.locator('#filesEntries button').first().waitFor();
168
+ };
169
+ const select = async filename => {
170
+ const buttons = page.locator('#filesEntries button[data-file-path]');
171
+ const index = (await buttons.evaluateAll(elements => elements.map(element => element.dataset.filePath))).indexOf(filename);
172
+ assert.ok(index >= 0, 'Missing project entry: ' + filename); await buttons.nth(index).click();
173
+ };
174
+ const show = async filename => {
175
+ await page.evaluate(filename => window.hccFiles.open(filename), filename);
176
+ await page.waitForFunction(filename => document.getElementById('filesMeta').textContent.startsWith(filename + ' ·'), filename);
177
+ };
178
+ const fits = async () => {
179
+ const box = await page.locator('#filesPanel').boundingBox(), viewport = page.viewportSize();
180
+ assert.ok(box && box.x >= 0 && box.y >= 0 && box.x + box.width <= viewport.width + 1 && box.y + box.height <= viewport.height + 1, JSON.stringify(box));
181
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
182
+ };
183
+ try {
184
+ await page.goto(base + '/?token=' + token);
185
+ await page.waitForFunction(() => window.hccFiles && window.hccHandoff?.actionToken &&
186
+ /(已连接|Connected)$/.test(document.getElementById('handoffConnection').textContent));
187
+ await page.locator('#projectSelect').selectOption(first);
188
+ await page.waitForFunction(project => window.hccHandoff?.projectRoot === project, first);
189
+ assert.equal(await page.title(), 'hello-cc'); assert.equal(new URL(page.url()).origin, base);
190
+ assert.ok((await page.locator('body').innerText()).includes('hello-cc'));
191
+ assert.equal(await page.locator('vite-error-overlay,nextjs-portal,#webpack-dev-server-client-overlay').count(), 0);
192
+ page.on('request', request => {
193
+ const url = new URL(request.url());
194
+ if (url.origin !== base || !url.pathname.startsWith('/api/')) return;
195
+ const item = { method: request.method(), path: url.pathname, project: url.searchParams.get('root') };
196
+ if (url.pathname.startsWith('/api/files/')) receipt.fileRequests.push(item);
197
+ if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method())) receipt.writeRequests.push(item);
198
+ });
199
+ await open(); await fits();
200
+ const names = await page.locator('#filesEntries button').evaluateAll(buttons => buttons.map(button => button.dataset.filePath));
201
+ assert.ok(names.includes('README.md') && names.includes('dist'));
202
+ assert.equal(await page.evaluate(() => window.hccHandoff.sessions.length), 0);
203
+ pass('standalone', 'standalone project files open with no Agent session');
204
+
205
+ await select('artifacts'); await page.locator('[data-file-path="artifacts/report.html"]').waitFor();
206
+ await select('artifacts/report.html'); await page.frameLocator('#filesFrame').locator('h1').waitFor();
207
+ assert.equal(await page.frameLocator('#filesFrame').locator('h1').innerText(), 'Milestone report');
208
+ assert.equal(await page.locator('#filesFrame').getAttribute('sandbox'), '');
209
+ await shot(page, 'desktop-file-preview-html');
210
+ await page.locator('#filesSourceBtn').click();
211
+ assert.ok((await page.locator('#filesPreview pre').innerText()).includes('<h1>Milestone report</h1>'));
212
+ await page.locator('#filesSourceBtn').click(); await page.frameLocator('#filesFrame').locator('h1').waitFor();
213
+ pass('html', 'directory navigation and static HTML source/rendered switch work');
214
+
215
+ await page.locator('#filesUp').click(); await page.locator('[data-file-path="README.md"]').waitFor();
216
+ await select('README.md'); await page.locator('#filesPreview h1').waitFor();
217
+ assert.equal(await page.locator('#filesPreview h1').innerText(), 'Project handoff');
218
+ assert.equal(await page.locator('#filesPreview li').count(), 3);
219
+ assert.equal(await page.locator('#filesPreview pre code').innerText(), 'const status = "complete";');
220
+ pass('markdown', 'Markdown headings, lists and code blocks render');
221
+ await show('src/main.mjs');
222
+ assert.ok((await page.locator('#filesPreview pre').innerText()).includes('<sample>'));
223
+ assert.equal(await page.locator('#filesPreview sample').count(), 0);
224
+ pass('text', 'source files display literal text');
225
+
226
+ await show('artifacts/chart.png');
227
+ await page.waitForFunction(() => document.querySelector('#filesPreview img')?.naturalWidth === 640);
228
+ const size = await page.locator('#filesPreview img').evaluate(image => ({ width: image.naturalWidth, height: image.naturalHeight, renderedWidth: image.clientWidth, renderedHeight: image.clientHeight }));
229
+ assert.equal(size.height, 320); assert.ok(Math.abs(size.renderedWidth / size.renderedHeight - 2) < 0.02);
230
+ await shot(page, 'desktop-file-preview-image');
231
+ pass('image', 'PNG dimensions and displayed aspect ratio match the fixture');
232
+
233
+ await show('artifacts/report.pdf'); await page.locator('#filesDownload').waitFor({ state: 'visible' });
234
+ const pdfUrl = await page.locator('#filesFrame').getAttribute('src'); assert.match(pdfUrl, /^blob:/);
235
+ assert.equal(await page.locator('#filesDownload').getAttribute('href'), pdfUrl);
236
+ const blob = await page.evaluate(url => window.__hccPreviewBlobs.get(url), pdfUrl);
237
+ assert.equal(blob.type, 'application/pdf');
238
+ const expectedPdfHash = sha256(files.get(path.join(first, 'artifacts/report.pdf')));
239
+ assert.equal(blob.size, files.get(path.join(first, 'artifacts/report.pdf')).length);
240
+ const downloading = page.waitForEvent('download'); await page.locator('#filesDownload').click(); const download = await downloading;
241
+ assert.equal(download.suggestedFilename(), 'report.pdf');
242
+ const stream = await download.createReadStream(), chunks = [];
243
+ assert.ok(stream, 'Downloaded PDF must be readable'); for await (const chunk of stream) chunks.push(chunk);
244
+ assert.equal(sha256(Buffer.concat(chunks)), expectedPdfHash);
245
+ receipt.pdf = { mime: blob.type, bytes: blob.size, sha256: expectedPdfHash, filename: download.suggestedFilename() };
246
+ await shot(page, 'desktop-file-preview-pdf-handoff');
247
+ pass('pdf', 'typed PDF Blob and downloaded bytes match the saved file');
248
+
249
+ await show('artifacts/sample.svg'); assert.ok((await page.locator('#filesPreview pre').innerText()).includes('<svg'));
250
+ assert.equal(await page.locator('#filesPreview svg').count(), 0);
251
+ await show('artifacts/data.bin'); assert.ok((await page.locator('#filesPreview').innerText()).trim().length > 0);
252
+ assert.equal(await page.locator('#filesPreview iframe,#filesPreview img').count(), 0);
253
+ pass('formats', 'SVG remains source text and unsupported binary has an explicit state');
254
+ await show('large.txt'); assert.ok((await page.locator('#filesPreviewStatus').innerText()).trim().length > 0);
255
+ const large = await page.locator('#filesPreview pre').innerText();
256
+ assert.ok(Buffer.byteLength(large) <= 1024 * 1024 && large.length < String(files.get(path.join(first, 'large.txt'))).length);
257
+ pass('bounded-text', 'large text remains bounded with a visible truncation notice');
258
+
259
+ await page.locator('#filesClose').click(); await page.setViewportSize(receipt.viewports[1]);
260
+ await open(); await fits(); await shot(page, 'mobile-file-preview-list');
261
+ await select('README.md'); await page.locator('#filesPreview h1').waitFor(); await fits();
262
+ await shot(page, 'mobile-file-preview-markdown');
263
+ await page.locator('#filesBack').click(); assert.equal(await page.locator('#filesEntries').isVisible(), true);
264
+ await page.locator('#filesClose').click(); assert.equal(await page.locator('#filesDialog').isVisible(), false);
265
+ pass('mobile', '390px list, preview, back and close fit the viewport');
266
+
267
+ await page.setViewportSize(receipt.viewports[0]);
268
+ receipt.lateFile = await delayedPreview(page, first, 'src/main.mjs', () => show('README.md'));
269
+ assert.equal(await page.locator('#filesName').innerText(), 'README.md');
270
+ assert.equal(await page.locator('#filesPreview h1').innerText(), 'Project handoff');
271
+ pass('late-file', 'late previous-file response cannot replace the latest preview');
272
+ await delayedPreview(page, first, 'src/main.mjs', () => page.locator('#filesClose').click());
273
+ assert.equal(await page.locator('#filesDialog').isVisible(), false);
274
+ assert.equal(await page.locator('#filesPreview').innerText(), '');
275
+ await open(); assert.equal(await page.locator('#filesName').innerText(), '');
276
+ pass('close-pending', 'closing a pending preview leaves the next dialog clean');
277
+
278
+ receipt.lateProject = await delayedPreview(page, first, 'src/main.mjs', async () => {
279
+ // The project selector is outside the modal. Close through its visible
280
+ // control before switching, as a user would.
281
+ await page.locator('#filesClose').click();
282
+ await page.locator('#projectSelect').selectOption(second);
283
+ await page.waitForFunction(project => window.hccHandoff.projectRoot === project, second);
284
+ await open();
285
+ const entries = await page.locator('#filesEntries button').evaluateAll(buttons => buttons.map(button => button.dataset.filePath));
286
+ assert.ok(entries.includes('SECOND.md') && !entries.includes('README.md'));
287
+ await select('SECOND.md'); await page.locator('#filesPreview h1').waitFor();
288
+ });
289
+ assert.equal(await page.locator('#filesProject').innerText(), second);
290
+ assert.equal(await page.locator('#filesPreview h1').innerText(), 'Second project');
291
+ pass('project-switch', 'project switch scopes reads and fences the prior project response');
292
+ await page.locator('#filesClose').click();
293
+ assert.equal(await page.evaluate(() => window.hccHandoff.sessions.length), 0);
294
+ assert.deepEqual([...adapters.keys()], providers); assert.deepEqual(providers.map(provider => adapters.get(provider).sent || 0), sends);
295
+ assert.deepEqual(hashes(), before);
296
+ assert.deepEqual(receipt.writeRequests, [{ method: 'POST', path: '/api/projects/select', project: second }],
297
+ 'Only the explicit project switch may write selection metadata; previews must not mutate files or workers');
298
+ assert.ok(receipt.fileRequests.length > 0 && receipt.fileRequests.every(request => request.method === 'GET' && [first, second].includes(request.project)));
299
+ pass('readonly', 'preview preserves fixture bytes and makes no file writes or Agent sends');
300
+ assert.deepEqual(receipt.checks, FILE_PREVIEW_CHECKS); receipt.success = true;
301
+ } finally { await context.close(); }
302
+ }
@@ -43,7 +43,7 @@ reply(m,{});
43
43
  });
44
44
  `,{mode:0o700});
45
45
  function sourceHashes(){const result={};const walk=folder=>{for(const entry of fs.readdirSync(path.join(repo,folder),{withFileTypes:true})){const file=path.join(folder,entry.name);if(entry.isDirectory())walk(file);else if(entry.isFile())result[file]=createHash('sha256').update(fs.readFileSync(path.join(repo,file))).digest('hex');}};for(const folder of ['bin','lib','scripts','test'])walk(folder);for(const file of ['package.json','package-lock.json'])result[file]=createHash('sha256').update(fs.readFileSync(path.join(repo,file))).digest('hex');return result;}
46
- const evidence={dir,checks:[],screenshots:[],pageErrors:[],consoleErrors:[],browser:'Isolated installed Chrome + Playwright; browser-extension connectivity is not exercised',providerMode:'simulated protocol requests; production HTTP, SQLite, worker and browser paths; no inference',node:process.version,sourceRoot:repo,sourceFiles:sourceHashes(),startedAt:new Date().toISOString()};
46
+ const evidence={dir,checks:[],screenshots:[],pageErrors:[],consoleErrors:[],browser:'Browser plugin not available; isolated installed Chrome + Playwright',providerMode:'simulated protocol requests; production HTTP, SQLite, worker and browser paths; no inference',node:process.version,sourceRoot:repo,sourceFiles:sourceHashes(),startedAt:new Date().toISOString()};
47
47
  const check=(name,extra={})=>{evidence.checks.push({name,...extra});console.log('PASS '+name);};
48
48
  function hcc(...args){const r=spawnSync(node,[path.join(repo,'bin/hcc.mjs'),'--root',root,...args],{cwd:root,env,encoding:'utf8',timeout:30000});if(r.status!==0)throw Error('Isolated CLI failed '+args[0]);return r.stdout;}
49
49
  const ctx={root,dbPath:path.join(root,'.hello-cc','mesh.db')},events=createEventHelpers(),binding=createPeerBindingStore(events),peer=createPeerHelpers({now:()=>Math.floor(Date.now()/1000),...events});
@@ -51,12 +51,7 @@ const deps={...events,...binding,...peer,...createMessageStore(events),connect:(
51
51
  let browser,service,runtime=false,state,interactions,emit,adapter;
52
52
  const api=(method,route,body)=>nativeRequest(ctx,method,route,body);
53
53
  const port=await new Promise(resolve=>{const s=net.createServer();s.listen(0,'127.0.0.1',()=>{const p=s.address().port;s.close(()=>resolve(p));});}),base='http://127.0.0.1:'+port;
54
- const shot=async(page,name)=>{
55
- const file=path.join(dir,name+'.png');
56
- // Viewport changes can leave an old compositor frame after layout settles.
57
- await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
58
- await page.screenshot({path:file});evidence.screenshots.push(file);
59
- };
54
+ const shot=async(page,name)=>{const file=path.join(dir,name+'.png');await page.screenshot({path:file});evidence.screenshots.push(file);};
60
55
  const wait=(page,fn)=>page.waitForFunction(fn,null,{timeout:12000});
61
56
  function ask(id,method,params){state.turnId='native-turn';state.status='running';return interactions.request({requestId:id,sessionId:state.sessionId,turnId:state.turnId,method,kind:codexInteractionKind(method),params,validate:codexInteractionResponse,cancelled:cancelCodexInteraction(method)});}
62
57
  try{
@@ -102,6 +97,26 @@ await p1.locator('#nativeApprovals button[data-decision="accept"]').click();asse
102
97
  assert.equal(await p1.evaluate(()=>JSON.stringify(localStorage).includes('private-ui-form')),false);assert.equal(JSON.stringify(await api('GET','/workers/native-qa/state')).includes('private-ui-form'),false);check('MCP typed response reaches the original native request, optional defaults are omitted and answers are not retained');await p1.setViewportSize({width:1440,height:1000});
103
98
  const unsupported=ask('unsupported-url','mcpServer/elicitation/request',{mode:'url',serverName:'form-fixture',message:'Authenticate',url:'https://auth.example.invalid'});await p1.locator('#nativeRead').click();assert.equal(await p1.locator('#nativeApprovals button[data-decision="accept"]').isDisabled(),true);await p1.locator('#nativeApprovals button[data-decision="cancel"]').click();assert.deepEqual(await unsupported,{action:'cancel'});check('MCP URL request missing its elicitation identity has blocked acceptance and usable cancellation');
104
99
  const malformed=ask('unsupported-schema','mcpServer/elicitation/request',{...formParams,requestedSchema:{type:'object',properties:{nested:{type:'object'}}}});await p1.locator('#nativeRead').click();assert.equal(await p1.locator('#nativeApprovals button[data-decision="accept"]').isDisabled(),true);await p1.locator('#nativeApprovals button[data-decision="decline"]').click();assert.deepEqual(await malformed,{action:'decline'});check('unsupported MCP schema remains explicitly rejectable');
100
+ // A valid URL flow uses the same controls, with an isolated intercepted destination.
101
+ await a.route('https://auth.example.invalid/device*',route=>route.fulfill({status:200,contentType:'text/html',body:'<title>Synthetic authorization</title><h1>Complete the external flow</h1>'}));
102
+ const urlFlow=ask('url-flow','mcpServer/elicitation/request',{mode:'url',serverName:'form-fixture',message:'Synthetic external authorization',elicitationId:'private-ui-flow',url:'https://auth.example.invalid/device?code=private-ui-url-code'});
103
+ await p1.locator('#nativeRead').click();await p2.locator('#nativeRead').click();
104
+ assert.equal(await p1.locator('#nativeApprovals button[data-decision="accept"]').isEnabled(),true);
105
+ assert.equal(await p2.locator('#nativeApprovals button[data-decision="accept"]').isDisabled(),true);
106
+ assert.equal((await p1.locator('#nativeApprovals').innerText()).includes('private-ui-url-code'),false);
107
+ await p1.evaluate(()=>{window.hccTestOpen=window.open;window.open=()=>null;});
108
+ await p1.locator('#nativeApprovals button[data-decision="accept"]').click();
109
+ await wait(p1,()=>document.getElementById('nativeNotice').textContent.includes('pop-ups'));
110
+ assert.equal(interactions.snapshot().length,1);
111
+ await p1.evaluate(()=>{window.open=window.hccTestOpen;delete window.hccTestOpen;});
112
+ await shot(p1,'native-mcp-url-desktop');
113
+ const popupWait=a.waitForEvent('page');await p1.locator('#nativeApprovals button[data-decision="accept"]').click();const popup=await popupWait;
114
+ await popup.waitForURL('https://auth.example.invalid/device?code=private-ui-url-code');
115
+ assert.deepEqual(await urlFlow,{action:'accept'});assert.equal(await popup.evaluate(()=>window.opener),null);await popup.close();
116
+ const urlState=await api('GET','/workers/native-qa/state');
117
+ assert.equal(JSON.stringify(urlState).includes('private-ui-url-code'),false);assert.equal(JSON.stringify(urlState).includes('private-ui-flow'),false);
118
+ assert.equal(await p1.evaluate(()=>JSON.stringify(localStorage).includes('private-ui-url-code')),false);
119
+ check('valid URL opens only for the controller after exact acceptance; popup blocking preserves the request and links are not retained');
105
120
  const takeover=ask('takeover','mcpServer/elicitation/request',{mode:'form',requestedSchema:{type:'object',properties:{enabled:{type:'boolean'}},required:['enabled']}});await p1.locator('#nativeRead').click();await p2.locator('#nativeRead').click();await p2.locator('#claimControlBtn').click();await wait(p2,()=>window.hccHandoff.canControl);await wait(p1,()=>!window.hccHandoff.canControl);
106
121
  assert.equal(await p1.locator('#nativeApprovals button[data-decision="accept"]').isDisabled(),true);await p2.locator('#nativeApprovals [data-mcp-field="0"]').selectOption('0');await p2.locator('#nativeApprovals button[data-decision="accept"]').click();assert.deepEqual(await takeover,{action:'accept',content:{enabled:true}});check('lease takeover fences the old browser and enables the new controller');
107
122
  const mobile=ask('mobile','item/permissions/requestApproval',{permissions:{network:{enabled:true},fileSystem:{read:['/long/path/with-a-readable-file-name'],write:['/work']}}});
@@ -64,7 +64,7 @@ const protectedHashes = protectedFiles.map(file => [file, fs.existsSync(file) ?
64
64
  const evidence = { startedAt: new Date().toISOString(), node: process.version, directory, sourceRoot: repo,
65
65
  sourceFiles: sourceHashes(), modelInferenceCalled: false, requestInjection: false,
66
66
  providerMode: 'installed Codex; real model calls a disposable stdio MCP server; production HCC runtime and browser',
67
- browser: 'Isolated installed Chrome and Playwright; browser-extension connectivity is not exercised', checks: [], screenshots: [],
67
+ browser: 'Browser plugin not available; isolated installed Chrome and Playwright', checks: [], screenshots: [],
68
68
  pageErrors: [], consoleErrors: [], completed: false };
69
69
  const codexBin = option('--codex-bin', '/opt/homebrew/bin/codex');
70
70
  const tmux = process.env.HCC_ACCEPTANCE_TMUX || '/opt/homebrew/bin/tmux';
@@ -0,0 +1,128 @@
1
+ // Browser lifecycle checks against deterministic native adapters, with no model calls.
2
+ import assert from 'node:assert/strict';
3
+
4
+ export async function runNativeHandoffChecks({ browser, base, token, adaptersByPeer, api, service, select, check, shot, watch, browserInstrumentation, evidence, stopWeb }) {
5
+ const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
6
+ await context.addInitScript(browserInstrumentation);
7
+ const peer = 'qa-web-created', submitted = 'Fixture input submitted once before closing Web';
8
+ const draft = 'Unsent background Agent draft survives page close';
9
+ const requests = { launch: [], resume: [] };
10
+ const watchMutations = page => page.on('request', request => {
11
+ if (request.method() !== 'POST') return;
12
+ const pathname = new URL(request.url()).pathname;
13
+ if (pathname === '/api/sessions' && request.postDataJSON()?.id === peer) requests.launch.push(request.postDataJSON());
14
+ if (pathname === '/api/native/history/' + peer + '/resume') requests.resume.push(request.postDataJSON());
15
+ });
16
+ let page = await context.newPage(); watch(page); watchMutations(page);
17
+ try {
18
+ evidence.phase = 'Web background Agent creation and lifecycle';
19
+ await page.goto(base + '/?token=' + token);
20
+ await select(page, 'qa-codex');
21
+ await page.locator('#openStartDialog').click();
22
+ await page.locator('#startDialog').waitFor({ state: 'visible' });
23
+ await page.waitForFunction(() => !document.getElementById('agentStartBtn').disabled);
24
+ assert.equal(await page.locator('#kind').inputValue(), 'codex');
25
+ assert.equal(await page.locator('#transport').inputValue(), 'native');
26
+ await page.locator('#agentName').fill(peer);
27
+ await page.locator('#agentStartBtn').click();
28
+ await page.waitForFunction(peer => window.hccHandoff?.active === peer && window.hccHandoff.canControl &&
29
+ /(已连接|Connected)$/.test(document.getElementById('handoffConnection').textContent), peer);
30
+ const initial = await api('GET', '/workers/' + peer + '/state');
31
+ const serviceBefore = await api('GET', '/status');
32
+ const original = adaptersByPeer.get(peer);
33
+ assert.ok(original && initial.owner && initial.snapshot.sessionId);
34
+ assert.equal(requests.launch.length, 1);
35
+ assert.equal(requests.launch[0].transport, 'native');
36
+ assert.equal(original.opens.length, 1);
37
+ assert.equal(original.sent || 0, 0);
38
+ await shot(page, 'desktop-web-created-native');
39
+ check('visible New Agent form creates one independent native worker with the default background interface');
40
+
41
+ await page.locator('#nativeDraft').fill(submitted);
42
+ await page.locator('#nativeSend').click();
43
+ await page.waitForFunction(() => document.getElementById('nativeDraft').value === '');
44
+ // The fixture uses a long automatic poll interval; drain the durable queue now.
45
+ await service.poll();
46
+ const active = await api('GET', '/workers/' + peer + '/state');
47
+ assert.equal(original.sent, 1);
48
+ assert.ok(active.active_delivery && active.snapshot.turnId);
49
+ await page.locator('#nativeDraft').fill(draft);
50
+ await page.close();
51
+ original.emit({ type: 'message', itemId: 'background-live-output', text: 'BACKGROUND_EXECUTOR_CONTINUED_WITHOUT_WEB' });
52
+ const detached = await api('GET', '/workers/' + peer + '/state');
53
+ assert.equal(detached.owner, initial.owner);
54
+ assert.equal(detached.generation, initial.generation);
55
+ assert.equal(detached.snapshot.sessionId, initial.snapshot.sessionId);
56
+ assert.equal(detached.snapshot.turnId, active.snapshot.turnId);
57
+ page = await context.newPage(); watch(page); watchMutations(page);
58
+ await page.goto(base + '/?token=' + token);
59
+ await select(page, peer);
60
+ await page.waitForFunction(() => document.getElementById('nativeEvents').textContent.includes('BACKGROUND_EXECUTOR_CONTINUED_WITHOUT_WEB'));
61
+ assert.equal(await page.locator('#nativeDraft').inputValue(), draft);
62
+ assert.equal(original.sent, 1);
63
+ assert.equal(requests.launch.length, 1);
64
+ if (!await page.evaluate(() => window.hccHandoff.canControl)) await page.locator('#claimControlBtn').click();
65
+ await page.waitForFunction(() => window.hccHandoff.canControl);
66
+ await shot(page, 'desktop-web-reconnected-native');
67
+ check('closing and reopening Web preserves the new native owner, provider session, active turn and unsent draft without replay');
68
+
69
+ original.state.status = 'idle'; original.state.turnId = null;
70
+ original.emit({ type: 'completed', status: 'completed', turnId: active.snapshot.turnId });
71
+ await page.locator('#nativeRead').click();
72
+ await page.waitForFunction(() => document.getElementById('nativeInterrupt').disabled);
73
+ await page.locator('#nativeCloseConfirmed').check();
74
+ const closeReply = page.waitForResponse(response => response.request().method() === 'POST' &&
75
+ new URL(response.url()).pathname === '/api/sessions/' + peer + '/native/close');
76
+ await page.locator('#nativeClose').click();
77
+ assert.equal((await closeReply).status(), 200);
78
+ const closed = (await api('GET', '/status')).workers.find(worker => worker.peer === peer);
79
+ assert.equal(closed.status, 'closed'); assert.equal(closed.owned, false);
80
+ await page.locator('#openHistoryDialog').click();
81
+ await page.locator('#historyThreads [data-thread="' + peer + '"]').click();
82
+ await page.waitForFunction(() => document.getElementById('historySelected').textContent.includes('qa-web-created') &&
83
+ !document.getElementById('historyConfirmed').disabled);
84
+ assert.equal(await page.locator('#historyResume').isDisabled(), true);
85
+ await page.locator('#historyConfirmed').check();
86
+ // History confirmation submits restore directly; it does not require a second launch.
87
+ await page.locator('#historyResume').click();
88
+ await page.waitForFunction(peer => document.getElementById('startDialog').hidden &&
89
+ window.hccHandoff?.active === peer && window.hccHandoff.actionToken &&
90
+ /(已连接|Connected)$/.test(document.getElementById('handoffConnection').textContent), peer);
91
+ const resumed = await api('GET', '/workers/' + peer + '/state');
92
+ const replacement = adaptersByPeer.get(peer);
93
+ assert.notEqual(resumed.owner, initial.owner);
94
+ assert.equal(resumed.snapshot.sessionId, initial.snapshot.sessionId);
95
+ assert.equal(resumed.generation, initial.generation);
96
+ assert.notEqual(replacement, original);
97
+ assert.equal(replacement.opens.length, 1);
98
+ assert.equal(replacement.opens[0].sessionId, initial.snapshot.sessionId);
99
+ assert.equal(replacement.sent || 0, 0);
100
+ assert.equal(requests.launch.length, 1); assert.equal(requests.resume.length, 1);
101
+ assert.equal(await page.locator('#nativeNotice').innerText(), '');
102
+ assert.equal(await page.locator('#nativeCloseConfirmed').isChecked(), false);
103
+ assert.equal(await page.locator('#nativeDraft').inputValue(), draft);
104
+ await shot(page, 'desktop-native-history-resumed');
105
+ check('explicit retained-history confirmation resumes the same provider session in a new native owner without creating or replaying input');
106
+
107
+ // Close every Web view before stopping its HTTP service; native has a separate owner.
108
+ await Promise.all(browser.contexts().map(view => view.close()));
109
+ await stopWeb();
110
+ const serviceAfter = await api('GET', '/status');
111
+ const afterWebShutdown = await api('GET', '/workers/' + peer + '/state');
112
+ assert.equal(serviceAfter.pid, serviceBefore.pid);
113
+ assert.equal(serviceAfter.generation, serviceBefore.generation);
114
+ assert.equal(afterWebShutdown.owner, resumed.owner);
115
+ assert.equal(afterWebShutdown.snapshot.sessionId, resumed.snapshot.sessionId);
116
+ evidence.nativeHandoff = { providerMode: 'synthetic native adapter', nativeRuntimePid: serviceAfter.pid,
117
+ generation: initial.generation, originalOwner: initial.owner, resumedOwner: resumed.owner,
118
+ providerSessionId: resumed.snapshot.sessionId, launchRequests: requests.launch.length,
119
+ resumeRequests: requests.resume.length, originalInputs: original.sent, replayedInputs: replacement.sent || 0,
120
+ webShutdownPreservedNativeRuntime: true };
121
+ check('Web shutdown keeps the independently owned native runtime and resumed worker alive');
122
+ } catch (error) {
123
+ evidence.nativeHandoffFailure = { requests, message: error.message, current: page.isClosed() ? null : await page.evaluate(() => ({ active: window.hccHandoff?.active, project: window.hccHandoff?.projectRoot, canControl: window.hccHandoff?.canControl, startError: document.getElementById('startDialogError').textContent, startHidden: document.getElementById('startDialog').hidden })) };
124
+ evidence.nativeHandoffFailure.runtime = await api('GET', '/status');
125
+ if (!page.isClosed()) await shot(page, 'native-handoff-failure');
126
+ throw error;
127
+ } finally { await context.close(); }
128
+ }