@logicseek/hello-cc 1.0.5 → 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 (146) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/docs/codex-account.zh-CN.md +38 -0
  3. package/docs/codex-protocol.md +59 -0
  4. package/docs/commands.md +9 -0
  5. package/docs/commands.zh-CN.md +7 -0
  6. package/docs/macos-process-identity.md +2 -0
  7. package/docs/native.md +179 -14
  8. package/docs/native.zh-CN.md +136 -6
  9. package/docs/web-handoff.zh-CN.md +118 -8
  10. package/lib/cli/app.mjs +47 -4
  11. package/lib/cli/commands/coordination.mjs +14 -4
  12. package/lib/cli/commands/dsh.mjs +1 -1
  13. package/lib/cli/commands/gc.mjs +22 -15
  14. package/lib/cli/commands/hook.mjs +19 -3
  15. package/lib/cli/commands/install.mjs +80 -10
  16. package/lib/cli/commands/misc.mjs +36 -9
  17. package/lib/cli/commands/native.mjs +4 -38
  18. package/lib/cli/commands/peer.mjs +3 -0
  19. package/lib/cli/commands/run.mjs +13 -6
  20. package/lib/cli/commands/tmux.mjs +1 -2
  21. package/lib/cli-runtime.mjs +19 -2
  22. package/lib/core/coordination/results.mjs +27 -10
  23. package/lib/core/sessions/hook-root-identity.mjs +47 -0
  24. package/lib/core/sessions/launch.mjs +7 -1
  25. package/lib/db/connection.mjs +140 -42
  26. package/lib/db/schema.mjs +59 -0
  27. package/lib/diagnostics/codex-protocol.mjs +105 -0
  28. package/lib/guidance.mjs +21 -36
  29. package/lib/integrations/codex-account.mjs +143 -0
  30. package/lib/integrations/codex-interactions.mjs +13 -11
  31. package/lib/integrations/dsh-collaboration.mjs +6 -12
  32. package/lib/integrations/dsh-cordis.mjs +17 -13
  33. package/lib/integrations/dsh.mjs +16 -11
  34. package/lib/integrations/mcp-elicitation.mjs +116 -0
  35. package/lib/integrations/mcp-url-elicitation.mjs +25 -0
  36. package/lib/integrations/native/claude.mjs +24 -6
  37. package/lib/integrations/native/codex.mjs +38 -7
  38. package/lib/integrations/native/dsh-acp.mjs +67 -24
  39. package/lib/integrations/native/index.mjs +4 -1
  40. package/lib/integrations/native/interactions.mjs +2 -1
  41. package/lib/integrations/native/jsonrpc.mjs +37 -5
  42. package/lib/integrations/native/telemetry.mjs +30 -0
  43. package/lib/process/identity.mjs +4 -0
  44. package/lib/process/pinned-cwd.mjs +302 -0
  45. package/lib/process/pinned-guidance.mjs +138 -0
  46. package/lib/process/pinned-project-state.mjs +105 -0
  47. package/lib/process/selected-cwd-identity.mjs +95 -0
  48. package/lib/runtime/buffer-evidence.mjs +2 -1
  49. package/lib/runtime/client.mjs +9 -0
  50. package/lib/runtime/native/client.mjs +4 -1
  51. package/lib/runtime/native/history.mjs +140 -0
  52. package/lib/runtime/native/launcher.mjs +106 -0
  53. package/lib/runtime/native/service.mjs +64 -8
  54. package/lib/runtime/native/store.mjs +83 -58
  55. package/lib/runtime/paths.mjs +17 -2
  56. package/lib/runtime/private-file.mjs +76 -0
  57. package/lib/runtime/private-state.mjs +546 -0
  58. package/lib/runtime/project-context.mjs +2 -1
  59. package/lib/runtime/project-path.mjs +92 -20
  60. package/lib/runtime/project-trust.mjs +79 -0
  61. package/lib/runtime/projects.mjs +9 -3
  62. package/lib/runtime/state-migration.mjs +697 -0
  63. package/lib/runtime/state.mjs +69 -27
  64. package/lib/shared/automatic-result-key.mjs +6 -0
  65. package/lib/shared/json-file.mjs +55 -6
  66. package/lib/shared/redact.mjs +2 -1
  67. package/lib/ui/help.mjs +6 -4
  68. package/lib/web/agent-defaults.mjs +110 -0
  69. package/lib/web/auto-attach.mjs +3 -2
  70. package/lib/web/browser/contracts.d.ts +10 -0
  71. package/lib/web/browser/core.mjs +1997 -0
  72. package/lib/web/browser/project-data.mjs +158 -0
  73. package/lib/web/browser/session-sync.mjs +82 -0
  74. package/lib/web/browser-assets.mjs +19 -0
  75. package/lib/web/buffer-gc-runtime.mjs +2 -2
  76. package/lib/web/codex-app-server.mjs +111 -37
  77. package/lib/web/codex-sessions.mjs +61 -12
  78. package/lib/web/context-files.mjs +82 -0
  79. package/lib/web/cookie-auth.mjs +51 -3
  80. package/lib/web/external-sessions.mjs +76 -9
  81. package/lib/web/http-routes.mjs +238 -25
  82. package/lib/web/http.mjs +50 -4
  83. package/lib/web/liveness-reaper.mjs +2 -2
  84. package/lib/web/native-sessions.mjs +323 -14
  85. package/lib/web/project-contexts.mjs +236 -44
  86. package/lib/web/project-file-write.mjs +249 -0
  87. package/lib/web/project-files.mjs +268 -0
  88. package/lib/web/pty-sessions.mjs +11 -3
  89. package/lib/web/runtime-main.mjs +132 -80
  90. package/lib/web/session-serialize.mjs +87 -8
  91. package/lib/web/session-state-sync.mjs +98 -0
  92. package/lib/web/shutdown-diagnostics.mjs +38 -0
  93. package/lib/web/shutdown-response.mjs +21 -0
  94. package/lib/web/shutdown-state.mjs +91 -0
  95. package/lib/web/startup.mjs +103 -35
  96. package/lib/web/task-results.mjs +19 -19
  97. package/lib/web/tls.mjs +84 -33
  98. package/lib/web/tmux-sessions.mjs +73 -9
  99. package/lib/web/tmux-stream.mjs +36 -8
  100. package/lib/web/ui-agent-defaults.mjs +149 -0
  101. package/lib/web/ui-agent-start.mjs +330 -0
  102. package/lib/web/ui-codex-account.mjs +81 -0
  103. package/lib/web/ui-codex.mjs +105 -85
  104. package/lib/web/ui-command-palette.mjs +21 -5
  105. package/lib/web/ui-file-writes.mjs +220 -0
  106. package/lib/web/ui-files.mjs +231 -0
  107. package/lib/web/ui-handoff.mjs +10 -2
  108. package/lib/web/ui-history.mjs +175 -105
  109. package/lib/web/ui-i18n.mjs +280 -8
  110. package/lib/web/ui-interactions.mjs +107 -6
  111. package/lib/web/ui-native-timeline.mjs +326 -0
  112. package/lib/web/ui-native.mjs +93 -43
  113. package/lib/web/ui-review.mjs +5 -3
  114. package/lib/web/ui-safe-markdown.mjs +79 -0
  115. package/lib/web/ui-session-tools.mjs +224 -0
  116. package/lib/web/ui-template.mjs +29 -1968
  117. package/lib/web/ui-terminal-find.mjs +5 -3
  118. package/lib/web/ui-workbench.mjs +5 -3
  119. package/lib/web/ui-workspace.mjs +3 -0
  120. package/package.json +8 -2
  121. package/scripts/benchmark-session-sync.mjs +53 -0
  122. package/scripts/codex-protocol.mjs +88 -0
  123. package/scripts/dsh-approval-layers-acceptance.mjs +172 -0
  124. package/scripts/dsh-package-provenance.mjs +118 -0
  125. package/scripts/mcp-form-live-fixture.mjs +81 -0
  126. package/scripts/native-bounded-live-acceptance.mjs +193 -0
  127. package/scripts/native-fixture-root.mjs +49 -0
  128. package/scripts/native-installed-acceptance.mjs +611 -0
  129. package/scripts/native-stability-checks.mjs +40 -0
  130. package/scripts/regression-cookie-expiry.mjs +70 -0
  131. package/scripts/regression-http-transport.mjs +9 -0
  132. package/scripts/regression-terminal-marker.mjs +95 -0
  133. package/scripts/regression.mjs +481 -219
  134. package/scripts/shim-reentry-probe.mjs +105 -0
  135. package/scripts/web-acceptance-diagnostics.mjs +90 -0
  136. package/scripts/web-codex-account-acceptance.mjs +178 -0
  137. package/scripts/web-file-preview-checks.mjs +302 -0
  138. package/scripts/web-mcp-form-acceptance.mjs +134 -0
  139. package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
  140. package/scripts/web-native-handoff-checks.mjs +128 -0
  141. package/scripts/web-native-interaction-acceptance.mjs +219 -18
  142. package/scripts/web-native-interaction-diagnostics.mjs +80 -0
  143. package/scripts/web-native-model-acceptance.mjs +22 -7
  144. package/scripts/web-session-tools-checks.mjs +112 -0
  145. package/scripts/web-workbench-acceptance.mjs +435 -0
  146. package/scripts/web-workbench-installed-acceptance.mjs +122 -0
@@ -0,0 +1,112 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ // These assertions use the actual Web HTTP/native bridge with a simulated
6
+ // adapter. They never invoke a model, execute the displayed command, or send a draft.
7
+ export async function runSessionToolsChecks({ page, adapters, root, check, shot }) {
8
+ await page.setViewportSize({ width: 1440, height: 1000 });
9
+ assert.equal(await page.evaluate(() => window.hccHandoff.active), 'qa-codex');
10
+ const adapter = adapters.get('codex'), before = adapter.sent || 0;
11
+ const initialDraft = await page.locator('#nativeDraft').inputValue();
12
+ const priorMetrics = adapter.state.metrics, priorMetadata = adapter.state.runtimeMetadata;
13
+ const directory = fs.mkdtempSync(path.join(root, 'context-check-'));
14
+ const relative = path.basename(directory) + '/reference.txt';
15
+ const content = 'FIXTURE_CONTENT_MUST_NOT_BE_READ_OR_INSERTED';
16
+ fs.writeFileSync(path.join(directory, 'reference.txt'), content);
17
+ const expand = async label => {
18
+ if (!await page.locator(label).evaluate(element => element.parentElement.open)) await page.locator(label).click();
19
+ };
20
+ try {
21
+ await expand('#nativeContextLabel');
22
+ const listing = page.waitForResponse(response => response.url().includes('/api/context/files?') && response.request().method() === 'GET');
23
+ await page.locator('#nativeFileQuery').fill(relative);
24
+ await page.locator('#nativeFileSearch').click();
25
+ const response = await listing; assert.equal(response.status(), 200);
26
+ const body = await response.json(); assert.ok(body.paths.includes(relative));
27
+ assert.ok(body.paths.every(value => !path.isAbsolute(value)));
28
+ assert.equal(JSON.stringify(body).includes(content), false);
29
+ await page.waitForFunction(relative => [...document.querySelectorAll('#nativeFileResults option')].some(option => option.textContent === relative), relative);
30
+ const fileValue = await page.locator('#nativeFileResults option').filter({ hasText: relative }).getAttribute('value');
31
+ await page.locator('#nativeFileResults').selectOption(fileValue);
32
+ await page.locator('#nativeFileInsert').click();
33
+ assert.ok((await page.locator('#nativeDraft').inputValue()).includes('[file reference: ' + JSON.stringify(relative) + ']'));
34
+ await expand('#nativeContextLabel');
35
+ const sessionValue = await page.locator('#nativeSessionResults').evaluate(element => [...element.options].find(option => option.textContent.includes('qa-claude'))?.value);
36
+ assert.notEqual(sessionValue, undefined); await page.locator('#nativeSessionResults').selectOption(sessionValue);
37
+ await page.locator('#nativeSessionInsert').click();
38
+ const draft = await page.locator('#nativeDraft').inputValue();
39
+ assert.match(draft, /\[session reference: "claude:qa-claude/);
40
+ assert.equal(draft.includes(content), false); assert.equal(adapter.sent || 0, before);
41
+ assert.match(await page.locator('#nativeContextHelp').innerText(), /不会读取|not read/);
42
+ check('context file endpoint returns only relative names and file/session references edit the existing draft without sending');
43
+
44
+ await expand('#nativeMetricsLabel');
45
+ assert.match(await page.locator('#nativeMetricsSource').innerText(), /未报告|No upstream metrics/);
46
+ adapter.state.metrics = { source: 'fixture: codex thread/tokenUsage/updated', scope: 'session', observedAt: Date.now(), inputTokens: 0, outputTokens: 7, cachedInputTokens: 0, totalTokens: 7, contextWindow: 128000 };
47
+ adapter.state.runtimeMetadata = { model: 'fixture-reported-model', permissionMode: 'on-request' };
48
+ adapter.emit({ type: 'usage', metrics: adapter.state.metrics });
49
+ adapter.emit({ type: 'metadata', runtimeMetadata: adapter.state.runtimeMetadata });
50
+ await page.locator('#nativeRead').click();
51
+ await page.waitForFunction(() => document.getElementById('nativeMetricsSource').textContent.includes('fixture: codex thread/tokenUsage/updated'));
52
+ const metricRows = await page.locator('#nativeMetrics').evaluate(element => Object.fromEntries([...element.querySelectorAll('dt')].map(term => [term.textContent, term.nextElementSibling.textContent])));
53
+ assert.equal(metricRows['输入 token'] ?? metricRows['Input tokens'], '0');
54
+ assert.equal(metricRows['总 token'] ?? metricRows['Total tokens'], '7');
55
+ assert.match(metricRows['上下文占用'] ?? metricRows['Context occupancy'], /未报告|Not reported/);
56
+ assert.equal(metricRows['上下文窗口上限'] ?? metricRows['Context window limit'], '128000');
57
+ await expand('#nativeContextLabel');
58
+ assert.match(await page.locator('#nativeCapabilitySummary').innerText(), /fixture-reported-model/);
59
+ assert.match(await page.locator('#nativeCapabilitySummary').innerText(), /只读|read only/);
60
+ assert.equal(await page.locator('#nativeCommandRow').isVisible(), false);
61
+ assert.equal(adapter.sent || 0, before);
62
+ check('reported metrics preserve actual zero and source/scope while missing context occupancy remains unknown and settings stay read only');
63
+
64
+ adapter.emit({type:'item',turnId:'trace-turn-a',phase:'completed',item:{id:'trace-failure',type:'commandExecution',status:'failed',exitCode:2,command:'npm run fixture-check',aggregatedOutput:'TRACE_FAILURE missing fixture package'}});
65
+ adapter.emit({type:'item',turnId:'trace-turn-b',phase:'completed',item:{id:'trace-file',type:'fileChange',status:'completed',changes:[{path:'acceptance/changed.mjs',diff:'+TRACE_FILE_CHANGE'}]}});
66
+ adapter.emit({type:'message',turnId:'trace-turn-b',itemId:'trace-other',text:'TRACE_OTHER unrelated answer'});
67
+ await page.locator('#nativeRead').click();
68
+ await page.waitForFunction(() => document.getElementById('nativeEvents').textContent.includes('npm run fixture-check'));
69
+ await expand('#nativeTraceFilterLabel');
70
+ await page.locator('#nativeTraceKind').selectOption('failed');
71
+ await page.locator('#nativeTraceTurn').selectOption('trace-turn-a');
72
+ await page.locator('#nativeTraceQuery').fill('TRACE_FAILURE npm');
73
+ await page.waitForFunction(() => document.querySelectorAll('#nativeEvents [data-native-card]').length === 1);
74
+ assert.equal(await page.locator('#nativeEvents [data-native-card]').count(), 1);
75
+ assert.match(await page.locator('#nativeEvents').innerText(), /npm run fixture-check/);
76
+ assert.doesNotMatch(await page.locator('#nativeEvents').innerText(), /TRACE_OTHER|acceptance\/changed/);
77
+ await page.locator('#nativeTraceTurn').selectOption('trace-turn-b');
78
+ assert.match(await page.locator('#nativeEvents').innerText(), /没有匹配|No matching/);
79
+ await page.locator('#nativeTraceTurn').selectOption('trace-turn-a');
80
+ await page.locator('#nativeEvents button[data-native-locate]').click();
81
+ assert.equal(await page.locator('#nativeTrace').getAttribute('aria-pressed'), 'true');
82
+ const raw = page.locator('#nativeEvents article').filter({has:page.locator('details[open]')}).filter({hasText:'TRACE_FAILURE'});
83
+ assert.equal(await raw.count(), 1);assert.match(await raw.innerText(), /exitCode/);
84
+ await raw.locator('button[data-native-result]').click();
85
+ assert.equal(await page.locator('#nativeConversation').getAttribute('aria-pressed'), 'true');
86
+ assert.equal(await page.locator('#nativeTraceKind').inputValue(), 'all');
87
+ assert.equal(await page.locator('#nativeTraceQuery').inputValue(), '');
88
+ assert.equal(await page.locator('#nativeEvents details[open]').filter({hasText:'TRACE_FAILURE'}).count(), 1);
89
+ await page.locator('#nativeTraceKind').selectOption('files');
90
+ await page.locator('#nativeTraceTurn').selectOption('trace-turn-b');
91
+ await page.locator('#nativeTraceQuery').fill('acceptance/changed.mjs');
92
+ assert.equal(await page.locator('#nativeEvents [data-native-card]').count(), 1);
93
+ const changedFile = page.locator('#nativeEvents [data-native-card] details').first();
94
+ if (!await changedFile.evaluate(element => element.open)) await changedFile.locator('summary').click();
95
+ assert.match(await page.locator('#nativeEvents').innerText(), /acceptance\/changed.mjs/);
96
+ await page.locator('#nativeTraceClear').click();
97
+ assert.ok(await page.locator('#nativeEvents [data-native-card]').count() > 1);
98
+ assert.equal(adapter.sent || 0, before);
99
+ check('native retained-history query/turn/kind filters combine with AND and results locate raw events and return to expanded results');
100
+ await shot(page, 'desktop-session-context-and-metrics');
101
+ } finally {
102
+ adapter.state.metrics = priorMetrics;
103
+ adapter.state.runtimeMetadata = priorMetadata;
104
+ adapter.emit({ type: 'metadata', runtimeMetadata: priorMetadata || {} });
105
+ if (await page.locator('#nativeTraceClear').isVisible()) await page.locator('#nativeTraceClear').click();
106
+ await page.locator('#nativeConversation').click().catch(() => {});
107
+ await page.locator('#nativeDraft').fill(initialDraft);
108
+ await page.locator('#nativeRead').click();
109
+ for (const label of ['#nativeContextLabel', '#nativeMetricsLabel', '#nativeTraceFilterLabel']) if (await page.locator(label).evaluate(element => element.parentElement.open)) await page.locator(label).click();
110
+ fs.rmSync(directory, { recursive: true, force: true });
111
+ }
112
+ }
@@ -0,0 +1,435 @@
1
+ // Reproducible browser acceptance. Providers are local simulated adapters; no model calls.
2
+ // Use Node 24+, tmux and `npx playwright install chromium`. HCC_ACCEPTANCE_CHROME
3
+ // may select an existing browser. Output is evidence, not a release or device receipt.
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import net from 'node:net';
8
+ import assert from 'node:assert/strict';
9
+ import { spawnSync } from 'node:child_process';
10
+ import { createHash, randomUUID } from 'node:crypto';
11
+ import { DatabaseSync } from 'node:sqlite';
12
+ import { fileURLToPath, pathToFileURL } from 'node:url';
13
+ import { runSessionToolsChecks } from './web-session-tools-checks.mjs';
14
+ import { runFilePreviewChecks } from './web-file-preview-checks.mjs';
15
+ import { runNativeHandoffChecks } from './web-native-handoff-checks.mjs';
16
+ import { createNativeFixtureRoot } from './native-fixture-root.mjs';
17
+
18
+ const args = process.argv.slice(2);
19
+ if (args.includes('--help')) {
20
+ console.log('Usage: node scripts/web-workbench-acceptance.mjs [--output-dir DIR] [--output FILE] [--package-root DIR]');
21
+ console.log('Optional: HCC_ACCEPTANCE_PACKAGE_ROOT, HCC_ACCEPTANCE_PLAYWRIGHT, HCC_ACCEPTANCE_CHROME, HCC_ACCEPTANCE_TMUX. No model or account is used.');
22
+ process.exit(0);
23
+ }
24
+ const allowed = new Set(['--output-dir', '--output', '--package-root']);
25
+ const options = new Map();
26
+ for (let index = 0; index < args.length; index += 2) {
27
+ if (!allowed.has(args[index]) || !args[index + 1] || args[index + 1].startsWith('--')) throw new Error('Unknown or incomplete acceptance option: ' + args[index]);
28
+ options.set(args[index], args[index + 1]);
29
+ }
30
+ assert.ok(Number(process.versions.node.split('.')[0]) >= 24, 'Workbench acceptance requires Node 24+');
31
+ assert.notEqual(process.platform, 'win32', 'Run the tmux-based acceptance under WSL on Windows');
32
+ const scriptRoot = fileURLToPath(new URL('..', import.meta.url));
33
+ const repo = fs.realpathSync(path.resolve(options.get('--package-root') || process.env.HCC_ACCEPTANCE_PACKAGE_ROOT || scriptRoot));
34
+ const node = process.execPath;
35
+ const dir = path.resolve(options.get('--output-dir') || fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-workbench-evidence-')));
36
+ fs.mkdirSync(dir, { recursive: true });
37
+ const output = path.resolve(options.get('--output') || path.join(dir, 'evidence.json'));
38
+ const load = name => import(pathToFileURL(path.join(repo, name)).href);
39
+ const playwrightSpecifier = process.env.HCC_ACCEPTANCE_PLAYWRIGHT;
40
+ const { chromium } = await import(playwrightSpecifier && path.isAbsolute(playwrightSpecifier) ? pathToFileURL(playwrightSpecifier).href : playwrightSpecifier || 'playwright');
41
+ const [{ createEventHelpers }, { createPeerHelpers }, { createPeerBindingStore }, { createMessageStore },
42
+ { inspectProcessIdentity }, { startNativeService }, { nativeRequest }] = await Promise.all([
43
+ load('lib/db/events.mjs'), load('lib/core/peers/peer-helpers.mjs'), load('lib/db/stores/peers.mjs'),
44
+ load('lib/core/coordination/messages.mjs'), load('lib/process/identity.mjs'),
45
+ load('lib/runtime/native/service.mjs'), load('lib/runtime/native/client.mjs')
46
+ ]);
47
+ const { API_VERSION } = await load('lib/web/api-version.mjs');
48
+ const sandbox = await createNativeFixtureRoot('hcc-workbench-fixture-', { projectSubdir: 'project with spaces' });
49
+ const root = path.join(sandbox, 'project with spaces'), taskHome = path.join(sandbox, 'home'), bin = path.join(sandbox, 'bin');
50
+ for (const folder of [root, taskHome, bin]) fs.mkdirSync(folder, { recursive: true, mode: 0o700 });
51
+ const socket = 'hcc-workbench-' + randomUUID();
52
+ const env = {
53
+ PATH: [bin, path.dirname(node), process.env.PATH || '/usr/bin:/bin'].join(path.delimiter),
54
+ HOME: taskHome, XDG_CONFIG_HOME: path.join(taskHome, '.config'), XDG_CACHE_HOME: path.join(taskHome, '.cache'),
55
+ CODEX_HOME: path.join(taskHome, '.codex'), CLAUDE_CONFIG_DIR: path.join(taskHome, '.claude'),
56
+ DSH_HOME: path.join(taskHome, '.dsh'), SHELL: '/bin/sh', LANG: 'C.UTF-8', TMPDIR: sandbox,
57
+ HCC_NO_AUTO_INSTALL_TMUX: '1', HCC_WEB_TOKEN: randomUUID()
58
+ };
59
+ function hashes() {
60
+ const result = {};
61
+ function walk(relative) {
62
+ const target = path.join(repo, relative);
63
+ if (!fs.existsSync(target)) return;
64
+ if (fs.statSync(target).isDirectory()) {
65
+ for (const name of fs.readdirSync(target).sort()) walk(path.join(relative, name));
66
+ } else result[relative] = createHash('sha256').update(fs.readFileSync(target)).digest('hex');
67
+ }
68
+ for (const name of ['bin', 'lib', 'package.json', 'package-lock.json', 'scripts/web-workbench-acceptance.mjs', 'scripts/native-fixture-root.mjs', 'scripts/web-session-tools-checks.mjs', 'scripts/web-file-preview-checks.mjs', 'scripts/web-native-handoff-checks.mjs']) walk(name);
69
+ return result;
70
+ }
71
+ const evidence = {
72
+ schemaVersion: 1, dir, packageRoot: repo, packageVersion: JSON.parse(fs.readFileSync(path.join(repo, 'package.json'), 'utf8')).version,
73
+ node: process.version, platform: process.platform, checks: [], screenshots: [], errors: [], console: [], cleanup: {},
74
+ sourceFiles: hashes(), browser: 'Playwright 1.62.1; isolated headless context; repeatable CI runner',
75
+ mode: 'Simulated native adapters; actual HTTP, SQLite, native service and browser; no models or credentials',
76
+ startedAt: new Date().toISOString(), success: false
77
+ };
78
+ const check = name => { evidence.phase = name; evidence.checks.push(name); console.log('PASS ' + name); };
79
+ function hcc(...command) {
80
+ const result = spawnSync(node, [path.join(repo, 'bin/hcc.mjs'), '--root', root, ...command], { cwd: root, env, encoding: 'utf8', timeout: 30000, maxBuffer: 4 * 1024 * 1024 });
81
+ fs.appendFileSync(path.join(dir, 'cli.log'), command.join(' ') + '\n' + (result.stdout || '') + (result.stderr || ''));
82
+ if (result.error) throw result.error;
83
+ if (result.status !== 0) throw new Error('Isolated CLI failed: ' + command[0] + ' (see cli.log)');
84
+ return result.stdout;
85
+ }
86
+ const ctx = { root, dbPath: path.join(root, '.hello-cc', 'mesh.db') };
87
+ const events = createEventHelpers(), bindings = createPeerBindingStore(events), peers = createPeerHelpers({ ...events, now: () => Math.floor(Date.now() / 1000) });
88
+ const deps = { ...events, ...bindings, ...peers, ...createMessageStore(events), connect: () => new DatabaseSync(ctx.dbPath), detectBranch: () => '', liveProcessIdentity: pid => inspectProcessIdentity(pid).identity };
89
+ const adapters = new Map(), adaptersByPeer = new Map();
90
+ let service, browser, runtime = false, tmux;
91
+ const api = (method, route, body) => nativeRequest(ctx, method, route, body);
92
+ const port = await new Promise((resolve, reject) => {
93
+ const server = net.createServer(); server.once('error', reject); server.listen(0, '127.0.0.1', () => { const value = server.address().port; server.close(error => error ? reject(error) : resolve(value)); });
94
+ });
95
+ const base = 'http://127.0.0.1:' + port; evidence.url = base;
96
+ const shot = async (page, name) => { const file = path.join(dir, name + '.png'); await page.screenshot({ path: file, animations: 'disabled' }); evidence.screenshots.push(file); };
97
+ const select = async (page, id) => {
98
+ // Let the initial auto-selected socket finish before selecting another peer;
99
+ // cancelling a still-connecting socket intentionally emits a browser warning.
100
+ await page.waitForFunction(() => window.hccHandoff?.actionToken && /(已连接|Connected)$/.test(document.getElementById('handoffConnection').textContent));
101
+ if (await page.evaluate(() => window.hccHandoff.active) !== id) await page.locator('#sessions .session[data-id="' + id + '"] .session-select').click();
102
+ await page.waitForFunction(id => window.hccHandoff?.active === id && window.hccHandoff.actionToken && /(已连接|Connected)$/.test(document.getElementById('handoffConnection').textContent), id);
103
+ await page.locator('#nativeRead').click();
104
+ // The click handler starts an async read without exposing its promise. Await
105
+ // the public view read too, so later assertions observe a rendered snapshot.
106
+ await page.evaluate(() => window.hccNative.read());
107
+ };
108
+ function watch(page) {
109
+ page.on('pageerror', error => evidence.errors.push(error.message));
110
+ page.on('console', message => { if (['error', 'warning'].includes(message.type())) evidence.console.push({ text: message.text(), phase: evidence.phase, location: message.location() }); });
111
+ page.on('dialog', dialog => dialog.accept());
112
+ }
113
+ function browserInstrumentation() {
114
+ // Context init scripts also run in static preview and built-in PDF frames;
115
+ // those documents do not own workbench storage or transport instrumentation.
116
+ if (!['http:', 'https:'].includes(window.location.protocol)) return;
117
+ localStorage.setItem('hcc.lang', 'zh'); localStorage.setItem('hcc.theme', 'light');
118
+ const Original = window.WebSocket;
119
+ window.__hccAcceptance = { sockets: [], outboundTypes: [], frames: 0, bytes: 0, longTasks: [] };
120
+ window.WebSocket = class extends Original {
121
+ constructor(...values) { super(...values); window.__hccAcceptance.sockets.push(this); this.addEventListener('message', event => { window.__hccAcceptance.frames++; window.__hccAcceptance.bytes += typeof event.data === 'string' ? event.data.length : 0; }); }
122
+ send(data) {
123
+ let type = 'other';
124
+ try { const value = JSON.parse(data); if (['input', 'control', 'resize', 'state.snapshot.request'].includes(value.type)) type = value.type; } catch {}
125
+ window.__hccAcceptance.outboundTypes.push(type);
126
+ return super.send(data);
127
+ }
128
+ };
129
+ if (window.PerformanceObserver?.supportedEntryTypes?.includes('longtask')) new PerformanceObserver(list => {
130
+ window.__hccAcceptance.longTasks.push(...list.getEntries().map(entry => ({ start: entry.startTime, duration: entry.duration })));
131
+ }).observe({ type: 'longtask', buffered: true });
132
+ }
133
+ async function checkSharedSplit(page) {
134
+ const marker = randomUUID();
135
+ let requests = 0;
136
+ const track = request => { const url = new URL(request.url()); if (url.pathname === '/api/sessions' && url.searchParams.get('acceptance_shared') === marker) requests++; };
137
+ page.on('request', track);
138
+ try {
139
+ const frame = page.frames().find(candidate => new URL(candidate.url()).pathname === '/pane');
140
+ assert.ok(frame, 'The secondary surface must be an actual same-origin frame');
141
+ const sameBroker = await frame.evaluate(async () => {
142
+ const module = await import('/assets/web/browser/project-data.mjs');
143
+ return module.sharedProjectBroker() === window.parent.hccProjectReads;
144
+ });
145
+ const result = await page.evaluate(async marker => {
146
+ const child = document.getElementById('workspaceFrame').contentWindow;
147
+ // The auxiliary surface receives its parent's broker from this factory;
148
+ // it intentionally does not publish a second hccProjectReads global.
149
+ window.hccProjectReads.invalidate();
150
+ const route = '/api/sessions?acceptance_shared=' + encodeURIComponent(marker);
151
+ const [first, second] = await Promise.all([window.hccHandoff.api(route), child.hccHandoff.api(route)]);
152
+ const secondBefore = JSON.stringify(second);
153
+ first.acceptanceMutation = true;
154
+ const firstSessions = Array.isArray(first) ? first : first.sessions;
155
+ if (firstSessions?.[0]) firstSessions[0].acceptanceNestedMutation = true;
156
+ return { independentCopies: JSON.stringify(second) === secondBefore && first !== second,
157
+ independentDraftScopes: window.hccHandoff.draftScope !== child.hccHandoff.draftScope,
158
+ independentActionTokens: Boolean(window.hccHandoff.actionToken && child.hccHandoff.actionToken && window.hccHandoff.actionToken !== child.hccHandoff.actionToken) };
159
+ }, marker);
160
+ assert.equal(sameBroker, true);
161
+ assert.equal(requests, 1, 'Parallel same-root pane reads must share one actual HTTP request');
162
+ assert.equal(result.independentCopies, true);
163
+ assert.equal(result.independentDraftScopes, true);
164
+ assert.equal(result.independentActionTokens, true);
165
+ evidence.splitReadSharing = { ...result, sameBroker, actualHttpRequests: requests };
166
+ check('real split panes share one project GET while keeping response copies, drafts and action tokens independent');
167
+ } finally { page.off('request', track); }
168
+ }
169
+ async function extendedChecks(page, context) {
170
+ await page.setViewportSize({ width: 1440, height: 1000 });
171
+ await select(page, 'qa-codex');
172
+ const before = adapters.get('codex').sent || 0;
173
+ const draft = 'Unsent draft survives an actual WebSocket disconnect';
174
+ await page.locator('#nativeDraft').fill(draft);
175
+ await page.evaluate(() => { for (const socket of window.__hccAcceptance.sockets) if (socket.readyState === 1) socket.close(1000, 'acceptance reconnect'); });
176
+ await page.waitForFunction(() => !window.hccHandoff.canControl);
177
+ adapters.get('codex').emit({ type: 'message', itemId: 'gap-recovered', text: 'RECOVERED_AFTER_DISCONNECT' });
178
+ if (await page.locator('#reconnectBtn').isVisible()) await page.locator('#reconnectBtn').click();
179
+ await page.waitForFunction(() => /(已连接|Connected)$/.test(document.getElementById('handoffConnection').textContent) && document.getElementById('nativeEvents').textContent.includes('RECOVERED_AFTER_DISCONNECT'), null, { timeout: 15000 });
180
+ assert.equal(await page.locator('#nativeDraft').inputValue(), draft);
181
+ assert.equal(adapters.get('codex').sent || 0, before);
182
+ // A fresh socket has a fresh action token. Recovery must not silently take
183
+ // the prior lease; explicitly reclaim it through the visible control.
184
+ if (!await page.evaluate(() => window.hccHandoff.canControl)) await page.locator('#claimControlBtn').click();
185
+ await page.waitForFunction(() => window.hccHandoff.canControl);
186
+ check('actual WebSocket reconnect restores missing output without replaying unsent input');
187
+ await automaticReconnectChecks(page);
188
+
189
+ const observerContext = await browser.newContext({ viewport: { width: 1280, height: 900 } });
190
+ await observerContext.addInitScript(browserInstrumentation);
191
+ const observer = await observerContext.newPage(); watch(observer);
192
+ await observer.goto(base + '/?token=' + env.HCC_WEB_TOKEN); await select(observer, 'qa-codex');
193
+ assert.equal(await observer.locator('#nativeSend').isDisabled(), true);
194
+ await observer.locator('#claimControlBtn').click(); await observer.waitForFunction(() => window.hccHandoff.canControl);
195
+ await page.waitForFunction(() => !window.hccHandoff.canControl);
196
+ assert.equal(await page.locator('#nativeSend').isDisabled(), true);
197
+ await page.locator('#claimControlBtn').click(); await page.waitForFunction(() => window.hccHandoff.canControl);
198
+ await observer.waitForFunction(() => !window.hccHandoff.canControl);
199
+ await observerContext.close();
200
+ check('separate browser contexts preserve observer fences during lease takeover');
201
+
202
+ const start = performance.now();
203
+ for (let index = 0; index < 120; index++) adapters.get('codex').emit({ type: 'message', itemId: 'budget-' + index, text: 'Bounded retained message ' + index + '\n' + 'readable output '.repeat(80) });
204
+ for (let attempt = 0; attempt < 4; attempt++) {
205
+ await page.locator('#nativeRead').click();
206
+ if ((await page.locator('#nativeEvents').innerText()).includes('Bounded retained message 119')) break;
207
+ }
208
+ await page.waitForFunction(() => document.getElementById('nativeEvents').textContent.includes('Bounded retained message 119'));
209
+ const metrics = await page.evaluate(() => ({
210
+ cards: document.querySelectorAll('#nativeEvents [data-native-card]').length,
211
+ nodes: document.querySelectorAll('#nativeEvents *').length,
212
+ htmlBytes: new TextEncoder().encode(document.getElementById('nativeEvents').innerHTML).length,
213
+ frames: window.__hccAcceptance.frames, wireCharacters: window.__hccAcceptance.bytes,
214
+ maximumLongTaskMs: Math.max(0, ...window.__hccAcceptance.longTasks.map(item => item.duration))
215
+ }));
216
+ metrics.fixtureCatchupMs = performance.now() - start;
217
+ evidence.performance = { ...metrics, budgets: { retainedCards: 100, nativeDomNodes: 5000, nativeHtmlBytes: 2 * 1024 * 1024, fixtureCatchupMs: 12000, maximumLongTaskMs: 3000 } };
218
+ assert.ok(metrics.cards <= 100, 'Retained message cards must remain bounded');
219
+ assert.ok(metrics.nodes <= 5000 && metrics.htmlBytes <= 2 * 1024 * 1024, 'Native DOM budget exceeded');
220
+ assert.ok(metrics.fixtureCatchupMs <= 12000 && metrics.maximumLongTaskMs <= 3000, 'Controlled fixture responsiveness budget exceeded');
221
+ check('long-history fixture stays inside explicit DOM and responsiveness budgets');
222
+
223
+ const assets = await page.evaluate(() => [...new Set([
224
+ ...[...document.querySelectorAll('script[src],link[rel="stylesheet"][href]')].map(element => element.src || element.href),
225
+ ...performance.getEntriesByType('resource').map(entry => entry.name).filter(url => new URL(url).origin === location.origin && new URL(url).pathname.startsWith('/assets/'))
226
+ ])]);
227
+ evidence.assets = [];
228
+ for (const url of assets) {
229
+ assert.equal(new URL(url).origin, base, 'All workbench bootstrap assets must be same-origin');
230
+ const response = await context.request.get(url);
231
+ assert.equal(response.status(), 200, 'Public browser asset missing: ' + new URL(url).pathname);
232
+ const content = await response.body();
233
+ evidence.assets.push({ path: new URL(url).pathname, bytes: content.length, sha256: createHash('sha256').update(content).digest('hex'), contentType: response.headers()['content-type'] });
234
+ }
235
+ assert.ok(evidence.assets.length >= 4, 'Expected independently served browser assets');
236
+ assert.ok(evidence.assets.some(asset => asset.path === '/assets/web/browser/core.mjs'), 'Expected the native ESM browser entry');
237
+ assert.ok(evidence.assets.some(asset => asset.path === '/assets/web/browser/session-sync.mjs'), 'Expected the browser session-sync module');
238
+ assert.ok(evidence.assets.some(asset => asset.path === '/assets/web/ui-native.mjs'), 'Expected the native UI module');
239
+ assert.ok(evidence.assets.some(asset => asset.path === '/assets/web/ui-files.mjs'), 'Expected the project file preview module');
240
+ const cliHelp = hcc('--help'); assert.ok(cliHelp.includes('hello-cc') || cliHelp.includes('hcc'));
241
+ check('public installed CLI and current same-origin bootstrap assets read back successfully');
242
+ await shot(page, 'desktop-native-long-history');
243
+ }
244
+ async function automaticReconnectChecks(page) {
245
+ // Exercise the shipped reconnect timer and polling path with actual sockets.
246
+ // Only the session-list response is faulted; no implementation is replaced.
247
+ const draft = 'Automatic recovery keeps this draft unsent';
248
+ await page.locator('#nativeDraft').fill(draft);
249
+ const sent = adapters.get('codex').sent || 0;
250
+ const outboundStart = await page.evaluate(() => window.__hccAcceptance.outboundTypes.length);
251
+ let unavailable = false, faultedReads = 0;
252
+ const writes = [];
253
+ const track = request => {
254
+ if (request.method() !== 'GET' && new URL(request.url()).pathname.startsWith('/api/sessions/')) writes.push(request.method() + ' ' + new URL(request.url()).pathname);
255
+ };
256
+ const route = async requestRoute => {
257
+ const response = await requestRoute.fetch();
258
+ const body = await response.json();
259
+ if (unavailable) {
260
+ for (const session of body.sessions || []) if (session.id === 'qa-codex') session.status = 'disconnected';
261
+ faultedReads++;
262
+ }
263
+ await requestRoute.fulfill({ response, json: body });
264
+ };
265
+ const pattern = /\/api\/sessions(?:\?|$)/;
266
+ page.on('request', track);
267
+ await page.route(pattern, route);
268
+ try {
269
+ const closed = await page.evaluate(async () => {
270
+ window.hccProjectReads.invalidate();
271
+ await window.hccHandoff.refreshSessions();
272
+ const sockets = window.__hccAcceptance.sockets;
273
+ const current = sockets.filter(socket => socket.readyState === WebSocket.OPEN);
274
+ if (current.length !== 1) throw new Error('Expected exactly one connected primary socket');
275
+ await new Promise(resolve => {
276
+ current[0].addEventListener('close', resolve, { once: true });
277
+ current[0].close(1000, 'acceptance automatic recovery');
278
+ });
279
+ const closedAt = performance.now();
280
+ return { socketCount: sockets.length, closedAt };
281
+ });
282
+ // Enable the list fault only after onclose has scheduled its first timer.
283
+ unavailable = true;
284
+ closed.listRefreshMs = await page.evaluate(async closedAt => {
285
+ window.hccProjectReads.invalidate();
286
+ await window.hccHandoff.refreshSessions();
287
+ return performance.now() - closedAt;
288
+ }, closed.closedAt);
289
+ assert.ok(closed.listRefreshMs < 600, 'Faulted list must arrive before the first reconnect timer can fire');
290
+ assert.ok(faultedReads > 0);
291
+ // The first timer has a 600–900ms delay. Keep the real clock running long
292
+ // enough for it to observe the disconnected list and leave no timer active.
293
+ await page.waitForFunction(closedAt => performance.now() - closedAt >= 1300, closed.closedAt);
294
+ await page.waitForFunction(() => document.getElementById('connState').dataset.stateKey === 'offline');
295
+ assert.equal(await page.evaluate(() => window.__hccAcceptance.sockets.length), closed.socketCount);
296
+ assert.equal(await page.evaluate(() => Boolean(window.hccHandoff.actionToken || window.hccHandoff.canControl)), false);
297
+ adapters.get('codex').emit({ type: 'message', itemId: 'automatic-gap', text: 'RECOVERED_AUTOMATICALLY_AFTER_LIST_OUTAGE' });
298
+ unavailable = false;
299
+ await page.evaluate(async () => { window.hccProjectReads.invalidate(); await window.hccHandoff.refreshSessions(); });
300
+ await page.waitForFunction(() => /(已连接|Connected)$/.test(document.getElementById('handoffConnection').textContent) && document.getElementById('nativeEvents').textContent.includes('RECOVERED_AUTOMATICALLY_AFTER_LIST_OUTAGE'), null, { timeout: 15000 });
301
+ assert.equal(await page.evaluate(() => window.__hccAcceptance.sockets.length), closed.socketCount + 1);
302
+ assert.equal(await page.locator('#nativeDraft').inputValue(), draft);
303
+ assert.equal(adapters.get('codex').sent || 0, sent);
304
+ assert.equal(await page.evaluate(() => window.hccHandoff.canControl), false);
305
+ assert.deepEqual(writes, [], 'Automatic recovery must not replay input, approvals or control actions');
306
+ const outboundMutations = await page.evaluate(start => window.__hccAcceptance.outboundTypes.slice(start).filter(type => ['input', 'control', 'resize'].includes(type)), outboundStart);
307
+ assert.deepEqual(outboundMutations, [], 'Automatic recovery must not send WebSocket control or input frames');
308
+ evidence.automaticReconnect = { faultedReads, listRefreshMs: closed.listRefreshMs, unavailableObservedMs: 1300, newSockets: 1, automatic: true, draftPreserved: true, replayedWrites: writes.length, outboundMutations: outboundMutations.length, explicitControlReclaim: true };
309
+ } finally {
310
+ page.off('request', track);
311
+ await page.unroute(pattern, route);
312
+ }
313
+ await page.locator('#claimControlBtn').click();
314
+ await page.waitForFunction(() => window.hccHandoff.canControl);
315
+ check('automatic reconnect resumes after a transient disconnected session list without replay or implicit control reclaim');
316
+ }
317
+ async function projectLifecycleChecks() {
318
+ const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
319
+ await context.addInitScript(browserInstrumentation);
320
+ const page = await context.newPage(); watch(page);
321
+ try {
322
+ await page.goto(base + '/?token=' + env.HCC_WEB_TOKEN + '&project=' + encodeURIComponent(root + '/'));
323
+ await select(page, 'qa-codex');
324
+ assert.equal(await page.evaluate(() => window.hccHandoff.projectRoot), fs.realpathSync(root));
325
+ adapters.get('codex').emit({ type: 'message', itemId: 'canonical-route', text: 'CANONICAL_ROOT_LIVE_UPDATE' });
326
+ await page.waitForFunction(() => document.getElementById('nativeEvents').textContent.includes('CANONICAL_ROOT_LIVE_UPDATE'));
327
+ check('trailing-slash project URL canonicalizes and receives live state without manual refresh');
328
+ const draft = 'Unsent draft survives the persisted page lifecycle';
329
+ await page.locator('#nativeDraft').fill(draft);
330
+ const sent = adapters.get('codex').sent || 0;
331
+ await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true })));
332
+ await page.waitForFunction(() => !window.hccHandoff.actionToken);
333
+ await Promise.all([
334
+ page.waitForEvent('domcontentloaded', { timeout: 15000 }),
335
+ page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true })))
336
+ ]);
337
+ await select(page, 'qa-codex');
338
+ assert.equal(await page.locator('#nativeDraft').inputValue(), draft);
339
+ assert.equal(adapters.get('codex').sent || 0, sent);
340
+ adapters.get('codex').emit({ type: 'message', itemId: 'persisted-resume', text: 'PERSISTED_PAGE_LIVE_UPDATE' });
341
+ await page.waitForFunction(() => document.getElementById('nativeEvents').textContent.includes('PERSISTED_PAGE_LIVE_UPDATE'));
342
+ check('simulated persisted pagehide/pageshow reload restores live connection and unsent draft');
343
+ await shot(page, 'desktop-project-lifecycle-restored');
344
+ evidence.lifecycleScope = 'Synthetic persisted PageTransitionEvents verify the reload handler; actual browser bfcache admission is not claimed';
345
+ } finally { await context.close(); }
346
+ }
347
+ try{
348
+ tmux = process.env.HCC_ACCEPTANCE_TMUX || spawnSync('which',['tmux'],{encoding:'utf8'}).stdout.trim();
349
+ assert.ok(tmux && fs.existsSync(tmux), 'Install tmux or set HCC_ACCEPTANCE_TMUX');
350
+ const quotedTmux = "'" + tmux.replaceAll("'", "'\"'\"'") + "'";
351
+ fs.writeFileSync(path.join(bin,'tmux'),'#!/bin/sh\nexec '+quotedTmux+' -L '+socket+' "$@"\n',{mode:0o700});
352
+ hcc('up','--no-discover','--no-guidance');
353
+ service=await startNativeService(ctx,deps,{pollMs:60000,adapterFactory:async(provider,options)=>{
354
+ const peer=options.env.HCC_PEER;
355
+ const state={provider,status:'idle',sessionId:peer,turnId:null,executorId:options.executorId,capabilities:{send:true,interrupt:true,close:true,resume:true}};
356
+ const adapter={opens:[],snapshot:()=>structuredClone(state),capabilities:state.capabilities,async open(input={}){this.opens.push(structuredClone(input));if(input.sessionId)state.sessionId=input.sessionId;return this.snapshot();},async send(input){this.sent=(this.sent||0)+1;state.status='running';state.turnId='qa-turn';this.active=input;return{status:'queued',turnId:state.turnId};},async interrupt(){state.status='idle';state.turnId=null;},async close(){state.status='closed';},emit(value){options.onEvent({provider,sessionId:state.sessionId,turnId:'qa-turn',...value});},state};
357
+ adapters.set(provider,adapter);adaptersByPeer.set(peer,adapter);return adapter;
358
+ }});
359
+ for(const provider of ['codex','claude','dsh'])await api('POST','/workers',{peer:'qa-'+provider,provider});
360
+ hcc('task','create','--title','统一 native 对话工作台');hcc('task','claim','--id','1','--peer','qa-codex');
361
+ const codex=adapters.get('codex'),claude=adapters.get('claude'),dsh=adapters.get('dsh');
362
+ for(let i=0;i<12;i++)codex.emit({type:'message',itemId:'history-'+i,text:'历史记录 '+(i+1)+':检查任务与输出的关联。\n\n这是已保留的执行器消息。'});
363
+ codex.emit({type:'item',phase:'started',item:{id:'tool',type:'commandExecution',command:'npm run test:unit',cwd:root,status:'inProgress'}});
364
+ codex.emit({type:'item',phase:'completed',item:{id:'tool',type:'commandExecution',status:'completed',aggregatedOutput:Array.from({length:240},(_,i)=>'PASS fixture '+i).join('\n'),exitCode:0}});
365
+ codex.emit({type:'item',phase:'completed',item:{id:'files',type:'fileChange',status:'completed',changes:[{path:'src/example.js',diff:'@@ -1 +1 @@\n-before\n+after'}]}});
366
+ const answer='检查完成。\n\n## 验证结果\n\n- 保留原始执行器\n- 对话与轨迹分开阅读\n\n```js\nconst status = "ready";\n```\n\n**3 项检查通过**。';
367
+ codex.emit({type:'message',itemId:'answer',text:answer});codex.emit({type:'completed',status:'completed',text:answer});
368
+ claude.emit({type:'message',messageId:'c1',text:'Claude fixture 的独立回答。'});claude.emit({type:'completed',status:'completed',text:'Claude fixture 的独立回答。'});
369
+ dsh.emit({type:'session.update',update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text:'DSH 唯一'}}});dsh.emit({type:'session.update',update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text:'回答。'}}});dsh.emit({type:'completed',status:'completed',text:'DSH 唯一回答。'});
370
+ hcc('web','--local','--port',String(port),'--no-discover','--no-guidance');runtime=true;
371
+ browser=await chromium.launch({headless:true,...(process.env.HCC_ACCEPTANCE_CHROME?{executablePath:process.env.HCC_ACCEPTANCE_CHROME}:{})});
372
+ const context=await browser.newContext({viewport:{width:1440,height:1000},permissions:['clipboard-read','clipboard-write']});await context.addInitScript(browserInstrumentation);
373
+ const p=await context.newPage();watch(p);
374
+ await p.goto(base+'/?token='+env.HCC_WEB_TOKEN);await p.waitForFunction(()=>window.hccHandoff?.actionToken);await select(p,'qa-codex');
375
+ assert.equal(await p.title(),'hello-cc');assert.ok((await p.locator('body').innerText()).length>200);assert.equal(await p.locator('vite-error-overlay,nextjs-portal').count(),0);check('page identity, meaningful content, no framework overlay');
376
+ assert.equal(await p.locator('#activeTitle').innerText(),'统一 native 对话工作台');assert.equal(await p.locator('#activeDetails').getAttribute('open'),null);assert.ok(await p.locator('#handoffAccess').isVisible());
377
+ await p.locator('#activeDetails summary').click();assert.ok((await p.locator('#activeIdentity').innerText()).includes('qa-codex'));await p.locator('#activeDetails summary').click();check('task title and compact identity disclosure keep control visible');
378
+ await p.locator('#nativeConversation').click();assert.equal(await p.locator('#nativeEvents h2').filter({hasText:'验证结果'}).count(),1);assert.equal(await p.locator('#nativeEvents').locator('script').count(),0);assert.ok((await p.locator('#nativeHistoryRange').innerText()).includes('100'));check('safe structured native messages and bounded history notice');
379
+ await p.locator('#nativeEvents button[data-native-copy]').last().click();assert.equal(await p.evaluate(()=>navigator.clipboard.readText()),'const status = "ready";');check('shared Markdown and full code copy');
380
+ const tool=p.locator('#nativeEvents details').filter({has:p.locator('summary').filter({hasText:'npm run test:unit'})});await tool.locator('summary').click();await tool.scrollIntoViewIfNeeded();
381
+ await p.evaluate(()=>{const s=document.getElementById('nativeScroll');s.scrollTop=160;document.getElementById('nativeEvents').__first=document.getElementById('nativeEvents').firstChild;});await p.locator('#nativeDraft').fill('保留尚未发送的草稿');
382
+ await p.evaluate(()=>window.hccNative.read());assert.equal(await p.evaluate(()=>document.getElementById('nativeEvents').firstChild===document.getElementById('nativeEvents').__first),true);assert.equal(await p.locator('#nativeDraft').inputValue(),'保留尚未发送的草稿');assert.equal(await tool.getAttribute('open'),'');check('unchanged snapshots preserve DOM, open tool and draft');
383
+ const oldTop=await p.locator('#nativeScroll').evaluate(e=>e.scrollTop);await select(p,'qa-claude');await select(p,'qa-codex');
384
+ assert.equal(await p.locator('#nativeDraft').inputValue(),'保留尚未发送的草稿');assert.equal(await tool.getAttribute('open'),'');assert.ok(Math.abs((await p.locator('#nativeScroll').evaluate(e=>e.scrollTop))-oldTop)<3);check('A-B-A restores native reading position, disclosure and draft');
385
+ codex.emit({type:'message',itemId:'late',text:'新的执行器消息,不应打断阅读。'});await p.locator('#nativeRead').click();assert.ok(Math.abs((await p.locator('#nativeScroll').evaluate(e=>e.scrollTop))-oldTop)<3);await p.locator('#nativeJump').click();await p.waitForFunction(()=>{const e=document.getElementById('nativeScroll');return e.scrollHeight-e.clientHeight-e.scrollTop<4;});check('new output preserves historical reading and back-to-latest works');
386
+ await p.locator('#nativeTrace').click();assert.ok((await p.locator('#nativeEvents').innerText()).includes('message'));await p.locator('#nativeConversation').click();check('conversation and trace navigation uses the same retained events');
387
+ await select(p,'qa-dsh');assert.equal(await p.locator('#nativeEvents .native-event-message').count(),1);assert.ok((await p.locator('#nativeEvents').innerText()).includes('DSH 唯一回答。'));await select(p,'qa-claude');assert.equal(await p.locator('#nativeEvents .native-event-message').count(),1);check('dsh and Claude completion text is not duplicated');
388
+ await select(p,'qa-codex');await p.locator('#nativeJump').click();await shot(p,'desktop-native-conversation');
389
+ evidence.phase='opening split';await p.locator('#splitBtn').click();evidence.phase='selecting split session';if(await p.locator('#workspaceSession').inputValue()!=='qa-claude')await p.locator('#workspaceSession').selectOption('qa-claude');await p.waitForFunction(()=>document.getElementById('workspaceFrame')?.contentWindow?.hccHandoff?.active==='qa-claude');await p.waitForFunction(()=>document.getElementById('workspaceStatus').hidden&&/(已连接|Connected)$/.test(document.getElementById('workspaceFrame').contentDocument.getElementById('handoffConnection').textContent));assert.equal(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+2),true);await checkSharedSplit(p);await shot(p,'desktop-native-split');evidence.phase='closing connected split';await p.locator('#workspaceClose').click();check('native split panes render without overflow');
390
+ await p.locator('#settingsBtn').click();await p.locator('#langSelect').selectOption('en');await p.locator('#themeSelect').selectOption('dark');await p.keyboard.press('Escape');await p.locator('#nativeTrace').click();assert.equal(await p.locator('#nativeConversation').innerText(),'Conversation');await shot(p,'desktop-native-dark-trace');
391
+ await p.locator('#settingsBtn').click();await p.locator('#langSelect').selectOption('zh');await p.locator('#themeSelect').selectOption('light');await p.keyboard.press('Escape');await p.locator('#nativeConversation').click();await p.setViewportSize({width:390,height:844});await p.locator('.mobile-nav [data-view="terminal"]').click();
392
+ assert.equal(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+2),true);const composer=await p.locator('.native-composer').boundingBox();assert.ok(composer.height>0&&composer.y+composer.height<=845);await shot(p,'mobile-native-conversation');check('390px mobile, both languages and themes, visible composer');
393
+ await p.setViewportSize({width:1024,height:768});assert.equal(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+2),true);check('1024px tablet no horizontal overflow');
394
+ await runSessionToolsChecks({page:p,adapters,root,check,shot});
395
+ const contextQuery = base + '/api/context/files?root=' + encodeURIComponent(root) + '&query=..%2F';
396
+ const unboundContext = await context.request.get(contextQuery, {headers:{'X-HCC-API-Version':String(API_VERSION)}});
397
+ assert.equal(unboundContext.status(),409);assert.equal((await unboundContext.json()).error.code,'PROJECT_PATH_CHANGED');
398
+ const projectIdentity = await p.evaluate(() => window.hccHandoff.projectIdentity);
399
+ const invalidContext = await context.request.get(contextQuery, {headers:{'X-HCC-API-Version':String(API_VERSION),'X-HCC-Root-Identity':projectIdentity,'X-HCC-Browser':'1'}});
400
+ assert.equal(invalidContext.status(),400);assert.equal((await invalidContext.json()).error.code,'INVALID_CONTEXT_QUERY');check('unbound cookie query is rejected; bound invalid context query returns HTTP 400');
401
+ await extendedChecks(p,context);
402
+ await projectLifecycleChecks();
403
+ await runFilePreviewChecks({browser,base,token:env.HCC_WEB_TOKEN,sandbox,registerPage:p,adapters,check,shot,watch,browserInstrumentation,evidence});
404
+ await runNativeHandoffChecks({browser,base,token:env.HCC_WEB_TOKEN,adaptersByPeer,api,service,select,check,shot,watch,browserInstrumentation,evidence,stopWeb:()=>{hcc('down');runtime=false;}});
405
+ assert.deepEqual(evidence.errors,[]);assert.deepEqual(evidence.console,[]);assert.deepEqual(hashes(),evidence.sourceFiles);check('zero browser errors and unchanged source hash during acceptance');evidence.success=true;
406
+ } catch (error) {
407
+ evidence.success = false; evidence.failure = { message: error.message, stack: error.stack };
408
+ console.error(error);
409
+ if (browser) for (const browserContext of browser.contexts()) for (const page of browserContext.pages()) {
410
+ try { await shot(page, 'failure-' + evidence.screenshots.length); fs.writeFileSync(path.join(dir, 'failure-dom.txt'), await page.locator('body').innerText()); } catch {}
411
+ }
412
+ } finally {
413
+ // Every teardown is attempted even when a prior cleanup operation fails.
414
+ const clean = async (name, action) => { try { await action(); evidence.cleanup[name] = true; } catch (error) { evidence.cleanup[name] = error.message; evidence.success = false; } };
415
+ await clean('browser', async () => { await browser?.close(); });
416
+ await clean('webRuntime', async () => { if (runtime) hcc('down'); });
417
+ await clean('nativeService', async () => { await service?.shutdown(); });
418
+ await clean('tmux', async () => {
419
+ if (!tmux) return;
420
+ const result = spawnSync(tmux, ['-L', socket, 'kill-server'], { env, encoding: 'utf8', timeout: 5000 });
421
+ // `hcc down` may have already removed the owned tmux server.
422
+ if (result.error) throw result.error;
423
+ const alive = spawnSync(tmux, ['-L', socket, 'list-sessions'], { env, encoding: 'utf8', timeout: 5000 });
424
+ if (alive.status === 0) throw new Error('Owned tmux server still has sessions');
425
+ });
426
+ const after = hashes();
427
+ evidence.sourceChanges = [...new Set([...Object.keys(evidence.sourceFiles), ...Object.keys(after)])].filter(file => evidence.sourceFiles[file] !== after[file]);
428
+ if (evidence.sourceChanges.length) { evidence.success = false; evidence.failure ||= { message: 'Source changed during acceptance' }; }
429
+ await clean('fixture', async () => fs.rmSync(sandbox, { recursive: true, force: true }));
430
+ evidence.finishedAt = new Date().toISOString();
431
+ fs.mkdirSync(path.dirname(output), { recursive: true });
432
+ fs.writeFileSync(output, JSON.stringify(evidence, null, 2) + '\n');
433
+ console.log('EVIDENCE ' + output);
434
+ if (!evidence.success) process.exitCode = 1;
435
+ }