@danceiny/gotry 0.2.0-rc.29 → 0.2.0-rc.31

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 (41) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/gotry-bootstrap.js +56 -83
  4. package/bin/gotry-extension-status.d.ts +12 -0
  5. package/bin/gotry-extension-status.js +113 -0
  6. package/bin/gotry-flyai-verification.d.ts +9 -0
  7. package/bin/gotry-flyai-verification.js +63 -0
  8. package/bin/gotry-inner.js +9 -2
  9. package/client/client.js +183 -0
  10. package/cordis.gotry-patch.yml +2 -0
  11. package/dist/capabilities/artifacts.js +41 -4
  12. package/dist/capabilities/doctor.js +7 -15
  13. package/dist/scripts/agent-planning-turn-deadline-e2e.js +2 -0
  14. package/dist/scripts/agent-planning-turn-deadline-tests.js +135 -2
  15. package/dist/scripts/artifact-client-contract-tests.js +4 -3
  16. package/dist/scripts/artifact-delivery-tests.js +31 -0
  17. package/dist/scripts/bootstrap-tests.js +19 -4
  18. package/dist/scripts/doctor-tests.js +36 -7
  19. package/dist/scripts/dsh-artifact-web-e2e.js +204 -43
  20. package/dist/scripts/flyai-setup-tests.js +75 -2
  21. package/dist/scripts/gotry-web-api-tests.js +114 -0
  22. package/dist/scripts/gotry-web-client-tests.js +28 -0
  23. package/dist/scripts/issue-271-liveness-tests.js +9 -1
  24. package/dist/scripts/turn-handoff-collect-tests.js +7 -1
  25. package/dist/scripts/turn-handoff-collect.js +68 -20
  26. package/dist/scripts/turn-handoff-job-tests.js +222 -0
  27. package/dist/scripts/turn-handoff-web-e2e.js +240 -0
  28. package/dist/src/artifact-delivery.js +26 -0
  29. package/dist/src/gotry-web-api.js +157 -0
  30. package/dist/src/index.js +30 -8
  31. package/dist/src/turn-deadline.js +113 -16
  32. package/dist/src/turn-handoff-job.js +124 -0
  33. package/extension/manifest.json +2 -2
  34. package/package.json +8 -2
  35. package/ts/capabilities/artifacts.ts +40 -5
  36. package/ts/scripts/turn-handoff-collect.ts +63 -21
  37. package/ts/src/artifact-delivery.ts +25 -0
  38. package/ts/src/gotry-web-api.ts +140 -0
  39. package/ts/src/index.ts +30 -8
  40. package/ts/src/turn-deadline.ts +111 -18
  41. package/ts/src/turn-handoff-job.ts +120 -0
@@ -21,9 +21,14 @@ import { EventEmitter } from 'node:events';
21
21
  import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
22
22
  import { tmpdir } from 'node:os';
23
23
  import { join, dirname, delimiter } from 'node:path';
24
- import { runDoctorChecks } from '../capabilities/doctor.js';
24
+ import { pathToFileURL } from 'node:url';
25
+ import { runDoctorChecks as diagnose } from '../capabilities/doctor.js';
25
26
  const repoRoot = join(import.meta.dirname, '..', '..');
26
27
  const bootstrap = join(repoRoot, 'bin', 'gotry-bootstrap.js');
28
+ const offlinePreload = pathToFileURL(join(repoRoot, 'scripts', 'doctor-test-isolation.mjs')).href;
29
+ process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --import=${offlinePreload}`.trim();
30
+ process.env.GOTRY_TEST_DOCTOR_PORT = '0';
31
+ const runDoctorChecks = (opts = {}) => diagnose({ ...opts, extensionPorts: [] });
27
32
  function runBootstrap(extraArgs, extraEnv, bootstrapPath = bootstrap) {
28
33
  try {
29
34
  const out = execFileSync('node', [bootstrapPath, ...extraArgs], {
@@ -156,7 +161,7 @@ console.log('9. calendar 子命令(setup 状态面 on/off/status + doctor 三态
156
161
  assert.ok(!r.stdout.includes('[gotry-doctor]'), 'summary 模式零 header(stdout 静默)');
157
162
  assert.match(r.stderr, /\[gotry\] doctor: \d+ 项待处理/, '隔离 HOME 有缺失项 → 一行摘要进 stderr');
158
163
  assert.match(r.stderr, /gotry_doctor/, '摘要带对话内指路');
159
- assert.match(r.stderr, /扩展=缺/, '缺失项人话=缺(降级类=半可用)');
164
+ assert.match(r.stderr, /扩展=待确认/, '没有安装或连接证据时,摘要显示待确认');
160
165
  }
161
166
  console.log('10. 启动一次性 doctor 摘要(--summary:stderr 一行/零写盘/恒 exit 0)OK');
162
167
  // 11. setupSidebar 落盘状态复核(2026-09-08 安装链修复):pnpm ≥10.5 在 profile 目录
@@ -388,6 +393,9 @@ console.log('18. onboarding CLI 跳过(非 TTY / CI / GOTRY_SETUP_SKIP / GOTRY_O
388
393
  const fixture = mkdtempSync(join(tmpdir(), 'gotry-onboard-pkg-'));
389
394
  mkdirSync(join(fixture, 'bin'), { recursive: true });
390
395
  copyFileSync(bootstrap, join(fixture, 'bin', 'gotry-bootstrap.js'));
396
+ copyFileSync(join(repoRoot, 'bin', 'gotry-flyai-verification.js'), join(fixture, 'bin', 'gotry-flyai-verification.js'));
397
+ copyFileSync(join(repoRoot, 'bin', 'gotry-process-liveness.js'), join(fixture, 'bin', 'gotry-process-liveness.js'));
398
+ copyFileSync(join(repoRoot, 'bin', 'gotry-extension-status.js'), join(fixture, 'bin', 'gotry-extension-status.js'));
391
399
  copyFileSync(join(repoRoot, 'package.json'), join(fixture, 'package.json'));
392
400
  const vendorDir = join(fixture, 'ts', 'dsh-runtime', 'vendor');
393
401
  mkdirSync(vendorDir, { recursive: true });
@@ -629,9 +637,16 @@ function buildPkgFixture(tmpRoot) {
629
637
  mkdirSync(pkgBin, { recursive: true });
630
638
  mkdirSync(pkgDist, { recursive: true });
631
639
  mkdirSync(join(dshDir, 'lib'), { recursive: true });
640
+ // The real patch now resolves native present before invoking this fake DSH.
641
+ const presentDir = join(pkgRoot, 'node_modules', '@deepseek-ai', 'dsh-tool-present');
642
+ mkdirSync(presentDir, { recursive: true });
643
+ writeFileSync(join(presentDir, 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh-tool-present', main: 'index.js' }));
644
+ writeFileSync(join(presentDir, 'index.js'), 'module.exports = {}\n');
632
645
  writeFileSync(join(pkgRoot, 'package.json'), `${JSON.stringify({ name: 'gotry', version: '0.0.1-test', type: 'module' })}\n`);
633
646
  copyFileSync(join(repoRoot, 'bin', 'gotry-inner.js'), join(pkgBin, 'gotry-inner.js'));
634
647
  copyFileSync(join(repoRoot, 'bin', 'gotry-bootstrap.js'), join(pkgBin, 'gotry-bootstrap.js'));
648
+ copyFileSync(join(repoRoot, 'bin', 'gotry-flyai-verification.js'), join(pkgBin, 'gotry-flyai-verification.js'));
649
+ copyFileSync(join(repoRoot, 'bin', 'gotry-extension-status.js'), join(pkgBin, 'gotry-extension-status.js'));
635
650
  copyFileSync(join(repoRoot, 'bin', 'gotry-runtime-resolution.js'), join(pkgBin, 'gotry-runtime-resolution.js'));
636
651
  copyFileSync(join(repoRoot, 'bin', 'gotry-process-liveness.js'), join(pkgBin, 'gotry-process-liveness.js'));
637
652
  copyFileSync(join(repoRoot, 'cordis.gotry-patch.yml'), join(pkgRoot, 'cordis.gotry-patch.yml'));
@@ -694,8 +709,8 @@ echo "bash-finished $$" >> "$GOTRY_TEST_INSTALLER_MARKER"
694
709
  }
695
710
  const closedFixtures = new WeakSet();
696
711
  function spawnInner(fixture, opts) {
697
- // NODE_OPTIONS:TTY 路径挂 fixture-local preload;非 TTY 路径清空(移除 tsx loader,temp 包无 tsx 会崩)。
698
- const nodeOptions = opts.tty ? `--require ${fixture.preloadPath}` : '';
712
+ // 保留离线诊断 preload,但移除父进程 tsx loader;临时包没有 tsx。
713
+ const nodeOptions = `--import=${offlinePreload}${opts.tty ? ` --require ${fixture.preloadPath}` : ''}`;
699
714
  const child = spawn(process.execPath, [join(fixture.pkgBin, 'gotry-inner.js'), 'web', '--no-open'], {
700
715
  env: {
701
716
  ...process.env,
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * doctor 能力层测试(离线,注入 repoRoot/homeDir/env,零安装零网络写路径):
3
- * 1. 空 tmp 环境 → agent-reach=missing / 扩展=missing / flyai=degraded(无 key)/ LLM key 恒 ok 且不进 broken
3
+ * 1. 空 tmp 环境 → agent-reach=missing / 扩展=degraded(连接未确认) / flyai=degraded(无 key)/ LLM key 恒 ok 且不进 broken
4
4
  * 2. 补齐假 .venv 双文件 → agent-reach=ok;只补 python → degraded(半可用态被显式区分)
5
5
  * 3. FLYAI_API_KEY 仅注入 → flyai=degraded(未验证);匹配验证回执 → flyai=ok
6
6
  * 4. nodeOk 边界(22.14/22.15/23.0)
@@ -25,9 +25,13 @@ import { existsSync } from 'node:fs';
25
25
  import { mkdtemp, mkdir, writeFile, rm, readFile } from 'node:fs/promises';
26
26
  import { tmpdir } from 'node:os';
27
27
  import { dirname, join } from 'node:path';
28
- import { runDoctorChecks, renderDoctorReportMd, nodeOk, probeStdout, scopeKeyFor, createRepairApprovalGate, runDoctorRepair } from '../capabilities/doctor.js';
28
+ import { runDoctorChecks as diagnose, renderDoctorReportMd, nodeOk, probeStdout, scopeKeyFor, createRepairApprovalGate, runDoctorRepair } from '../capabilities/doctor.js';
29
29
  import { endpointFingerprint, maskFlyaiKey, resolveFlyaiEndpoint, sha256Hex, writeFlyaiVerification } from '../capabilities/flyai-config.js';
30
30
  import { apply } from '../src/index.js';
31
+ const runDoctorChecks = (opts = {}) => diagnose({ ...opts, extensionPorts: [] });
32
+ // The registered model tool calls diagnose internally rather than this wrapper.
33
+ // Isolate its HTTP diagnostic seam too, without changing the product config.
34
+ await import(new URL('../../scripts/doctor-test-isolation.mjs', import.meta.url).href);
31
35
  const tmp = await mkdtemp(join(tmpdir(), 'gotry-doctor-test-'));
32
36
  const emptyRepo = join(tmp, 'repo-empty');
33
37
  const emptyHome = join(tmp, 'home-empty');
@@ -39,11 +43,10 @@ const byId = (id) => r1.items.find(i => i.id === id);
39
43
  assert.ok(byId('agent-reach'), 'agent-reach 项存在');
40
44
  assert.equal(byId('agent-reach').status, 'missing', `空仓 agent-reach=missing,实际 ${byId('agent-reach').status}`);
41
45
  assert.match(byId('agent-reach').fix ?? '', /npx @danceiny\/gotry doctor --fix/, 'missing 项带精确补装指引');
42
- assert.equal(byId('extension').status, 'missing', '空 home 扩展=missing');
43
- // #559 B 步:扩展 missing 必须显式说明「哪些工具不可用、哪些不受影响」
44
- assert.match(byId('extension').detail, /影响面/, '扩展 missing detail 必带影响面行(issue #559 B 步)');
45
- assert.match(byId('extension').detail, /gotry_session_search/, '扩展 missing detail 必显挂的工具名');
46
- assert.match(byId('extension').detail, /其它工具/, '扩展 missing detail 必显「其它不受影响」');
46
+ assert.equal(byId('extension').status, 'degraded', '空 home 无法断言扩展未安装');
47
+ assert.match(byId('extension').detail, /影响面/, '扩展 detail 必说明影响面');
48
+ assert.match(byId('extension').fix ?? '', /chrome:\/\/extensions/, '扩展未确认时可检查浏览器安装/启用状态');
49
+ assert.match(byId('extension').detail, /其它工具/, '扩展 detail 必说明其它工具不受影响');
47
50
  assert.equal(byId('flyai').status, 'degraded', '无 key flyai=degraded(有共享试用,非缺失)');
48
51
  // #559 B 步:无 key flyai 必须显式说明「匿名共享额度」影响面
49
52
  assert.match(byId('flyai').detail, /影响面/, 'flyai 无 key detail 必带影响面行(issue #559 B 步)');
@@ -155,6 +158,32 @@ console.log('5b. npm 提升布局解析链(map-tools/ask-user 误报修复)OK');
155
158
  await rm(vend, { recursive: true, force: true });
156
159
  }
157
160
  console.log('5c. 随包 vendor 布局(map-tools tarball 分发面)OK');
161
+ // #651: the model-facing diagnostic must recognize the same Chrome Store
162
+ // installation as the CLI without requiring the local unpacked directory.
163
+ {
164
+ const home = join(tmp, 'chrome-store-home');
165
+ const browserRoot = process.platform === 'darwin'
166
+ ? join(home, 'Library', 'Application Support', 'Google', 'Chrome')
167
+ : process.platform === 'win32'
168
+ ? join(home, 'AppData', 'Local', 'Google', 'Chrome', 'User Data')
169
+ : join(home, '.config', 'google-chrome');
170
+ const profile = join(browserRoot, 'Profile 3');
171
+ const id = 'oeajpiccmonococjcegddlooeeohlbgd';
172
+ const manifest = join(profile, 'Extensions', id, '0.2.0.27_0', 'manifest.json');
173
+ await mkdir(dirname(manifest), { recursive: true });
174
+ await writeFile(manifest, JSON.stringify({ manifest_version: 3, version: '0.2.0.27', name: 'Stai Travel Bridge' }));
175
+ const prefs = join(profile, 'Secure Preferences');
176
+ await writeFile(prefs, JSON.stringify({ extensions: { settings: { [id]: { disable_reasons: [] } } } }));
177
+ const report = await runDoctorChecks({ repoRoot: emptyRepo, homeDir: home, env: {} });
178
+ const extension = report.items.find(item => item.id === 'extension');
179
+ assert.equal(extension.status, 'degraded');
180
+ assert.match(extension.detail, /已安装/);
181
+ assert.match(report.summary, /Stai Travel Bridge 扩展\(待确认\)/);
182
+ await writeFile(prefs, JSON.stringify({ extensions: { settings: { [id]: { state: 0 } } } }));
183
+ const disabled = (await runDoctorChecks({ repoRoot: emptyRepo, homeDir: home, env: {} })).items.find(item => item.id === 'extension');
184
+ assert.match(disabled.detail, /已安装.*停用/);
185
+ }
186
+ console.log('5d. Chrome 商店安装与停用状态,工具层不误报 missing/ready OK');
158
187
  // 6. MCP 工具面:gotry_doctor 注册 + isolated stateRoot 报告落盘
159
188
  const smokeRoot = await mkdtemp(join(tmpdir(), 'gotry-doctor-state-'));
160
189
  const registered = [];
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Fresh-profile, installed-package Web E2E for GoTry artifact cards.
3
3
  *
4
- * The only model endpoint is a local OpenAI-compatible SSE relay. The test
4
+ * The only model endpoint is a local Chat/Messages SSE relay. The test
5
5
  * starts `gotry web --no-open --port 0`, captures the authenticated `dsh web:`
6
6
  * URL, then drives the real composer with a fresh headless Chrome profile.
7
7
  * It verifies the public Client cards, path selection, the normal read → edit
@@ -12,7 +12,7 @@
12
12
  import assert from 'node:assert/strict';
13
13
  import { createHash } from 'node:crypto';
14
14
  import { createServer } from 'node:http';
15
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
15
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
16
16
  import { tmpdir } from 'node:os';
17
17
  import { basename, join } from 'node:path';
18
18
  import { spawn } from 'node:child_process';
@@ -63,21 +63,38 @@ function toolNames(body) {
63
63
  }).filter(Boolean);
64
64
  }
65
65
  function hasToolMessage(body) {
66
- return (body.messages ?? []).some(message => message.role === 'tool');
66
+ return (body.messages ?? []).some(message => message.role === 'tool'
67
+ || Array.isArray(message.content) && message.content.some(block => block?.type === 'tool_result'));
67
68
  }
68
69
  function lastUserText(body) {
69
- const message = [...(body.messages ?? [])].reverse().find(item => item.role === 'user');
70
+ const message = [...(body.messages ?? [])].reverse().find(item => item.role === 'user'
71
+ && (!Array.isArray(item.content) || item.content.some(block => block?.type === 'text')));
70
72
  const content = message?.content;
71
73
  if (typeof content === 'string')
72
74
  return content;
73
75
  return JSON.stringify(content ?? '');
74
76
  }
77
+ /** Current DSH uses DeepSeek Messages; keep the relay compatible with both wires. */
78
+ function messagesResponse(chatPayload) {
79
+ const first = JSON.parse(chatPayload.split('\n').find(line => line.startsWith('data: ')).slice(6));
80
+ const delta = first.choices[0].delta, call = delta.tool_calls?.[0];
81
+ const event = (type, value) => `event: ${type}\ndata: ${JSON.stringify({ type, ...value })}\n\n`;
82
+ return event('message_start', { message: { id: first.id, type: 'message', role: 'assistant', model: first.model,
83
+ content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 0 } } })
84
+ + event('content_block_start', { index: 0, content_block: call
85
+ ? { type: 'tool_use', id: call.id, name: call.function.name, input: {} } : { type: 'text', text: '' } })
86
+ + event('content_block_delta', { index: 0, delta: call
87
+ ? { type: 'input_json_delta', partial_json: call.function.arguments } : { type: 'text_delta', text: delta.content || '' } })
88
+ + event('content_block_stop', { index: 0 })
89
+ + event('message_delta', { delta: { stop_reason: call ? 'tool_use' : 'end_turn', stop_sequence: null }, usage: { output_tokens: 1 } })
90
+ + event('message_stop', {});
91
+ }
75
92
  async function startRelay() {
76
93
  const bodies = [];
77
94
  const servedTools = [];
78
95
  let phase = 'first-list';
79
96
  const server = createServer((req, res) => {
80
- if (req.method !== 'POST' || !req.url?.endsWith('/chat/completions')) {
97
+ if (req.method !== 'POST' || !/\/(chat\/completions|messages)$/.test(req.url || '')) {
81
98
  res.writeHead(404).end('not found');
82
99
  return;
83
100
  }
@@ -141,12 +158,26 @@ async function startRelay() {
141
158
  payload = toolResponse('artifact-third-read', 'gotry_artifacts_read', { path: 'trip-2027.html' });
142
159
  }
143
160
  else if (phase === 'third-final' && hasToolMessage(body)) {
161
+ phase = 'fourth-render';
144
162
  payload = textResponse('artifact-third-final', '已读取 HTML 产物,请使用列表中的「Open HTML preview」按钮验证原生预览。');
145
163
  }
164
+ else if (phase === 'fourth-render' && lastUserText(body).includes('生成')) {
165
+ phase = 'fourth-final';
166
+ servedTools.push('gotry_itinerary_render');
167
+ payload = toolResponse('artifact-fourth-render', 'gotry_itinerary_render', {
168
+ title: 'Native delivery fixture', basename: 'gotry-itinerary-native-delivery.html', fact_ids: [],
169
+ itinerary: { trip_start: '2027-04-01', trip_end: '2027-04-02',
170
+ stays: [{ place: 'Shanghai', check_in: '2027-04-01', check_out: '2027-04-02' }], od_segments: [] },
171
+ });
172
+ }
173
+ else if (phase === 'fourth-final' && hasToolMessage(body)) {
174
+ phase = 'done';
175
+ payload = textResponse('artifact-fourth-final', '原生文件交付已生成;本计划未核验。');
176
+ }
146
177
  else {
147
178
  payload = textResponse(`unexpected-${bodies.length}`, '');
148
179
  }
149
- res.end(payload);
180
+ res.end(req.url?.endsWith('/messages') ? messagesResponse(payload) : payload);
150
181
  });
151
182
  });
152
183
  await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
@@ -189,6 +220,14 @@ async function main() {
189
220
  const homeDir = mkdtempSync(join(tmpdir(), 'gotry-artifact-web-home-'));
190
221
  const dshHome = mkdtempSync(join(tmpdir(), 'gotry-artifact-web-dsh-'));
191
222
  const userDataDir = mkdtempSync(join(tmpdir(), 'gotry-artifact-web-chrome-'));
223
+ const verifier = join(homeDir, 'flyai-verifier.cjs');
224
+ writeFileSync(verifier, `#!/usr/bin/env node
225
+ if (process.env.FLYAI_API_KEY === 'sk-web-bad-candidate') {
226
+ process.stderr.write('HTTP 401 ' + process.env.FLYAI_API_KEY); process.exit(1);
227
+ }
228
+ process.stdout.write(JSON.stringify({data:{itemList:[]}}));
229
+ `);
230
+ chmodSync(verifier, 0o700);
192
231
  const fixture = '# Trip 2027\n\nDay 1: arrival\nDay 2: city walk\nDay 3: museum\nDay 4: rest\nDay 5: return\n';
193
232
  writeFileSync(join(workspaceDir, 'trip-2027.md'), fixture, { mode: 0o600 });
194
233
  const htmlSentinel = 'gotry-artifact-web-html-sentinel-must-not-run-in-host';
@@ -216,6 +255,7 @@ document.documentElement.setAttribute('data-${htmlSentinel}', 'host-ran-it');
216
255
  let server = null;
217
256
  let passed = false;
218
257
  let serverOutput = '';
258
+ const browserErrors = [];
219
259
  const assertions = {
220
260
  isolation: { home: homeDir, dshHome, workspace: workspaceDir, userDataDir, liveFlags: { GOTRY_SESSION_LIVE: '0', GOTRY_HBCLI_LIVE: '0', GOTRY_HOTELBYTE_SKILLS_LIVE: '0' } },
221
261
  };
@@ -225,7 +265,8 @@ document.documentElement.setAttribute('data-${htmlSentinel}', 'host-ran-it');
225
265
  const tarball = join(packageDir, readdirSync(packageDir).find(name => name.endsWith('.tgz')) || '');
226
266
  assert.ok(existsSync(tarball), 'npm pack did not produce a GoTry tarball');
227
267
  const packageJson = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
228
- await run('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', tarball], { cwd: consumerDir, env: { ...process.env, HOME: homeDir } });
268
+ await run('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', tarball], { cwd: consumerDir,
269
+ env: { ...process.env, HOME: homeDir, npm_config_cache: join(outputDir, 'npm-cache') } });
229
270
  const installedRoot = join(consumerDir, 'node_modules', '@danceiny', 'gotry');
230
271
  const installedBin = join(installedRoot, 'bin', 'gotry-inner.js');
231
272
  assert.ok(existsSync(installedBin), 'packed GoTry binary missing');
@@ -244,7 +285,15 @@ document.documentElement.setAttribute('data-${htmlSentinel}', 'host-ran-it');
244
285
  LLM_API_KEY: 'synthetic-artifact-web-key',
245
286
  LLM_BASE_URL: `http://127.0.0.1:${relay.port}/v1`,
246
287
  LLM_MODEL: 'synthetic-artifact-web',
288
+ DEEPSEEK_API_KEY: 'synthetic-artifact-web-key',
289
+ DEEPSEEK_BASE_URL: `http://127.0.0.1:${relay.port}/v1`,
290
+ GOTRY_FLYAI_CLI_BIN: verifier,
291
+ GOTRY_FLYAI_VERIFY_TIMEOUT_MS: '5000',
292
+ GOTRY_TURN_HANDOFF_ROOT: workspaceDir,
247
293
  };
294
+ delete env.FLYAI_API_KEY;
295
+ delete env.DEBUG_FLYAI_API_KEY;
296
+ delete env.DEBUG_FLYAI_MCP_URL;
248
297
  const child = spawn(process.execPath, [installedBin, 'web', '--no-open', '--port', '0'], { cwd: workspaceDir, env, stdio: ['ignore', 'pipe', 'pipe'] });
249
298
  server = child;
250
299
  child.stdout.on('data', chunk => { serverOutput += chunk.toString(); });
@@ -255,14 +304,15 @@ document.documentElement.setAttribute('data-${htmlSentinel}', 'host-ran-it');
255
304
  browser = await puppeteer.launch({ executablePath: CHROME, headless: true, userDataDir, args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'] });
256
305
  page = await browser.newPage();
257
306
  page.on('console', (message) => { serverOutput += `\n[browser console ${message.type()}] ${message.text()}`; });
258
- page.on('pageerror', (error) => { serverOutput += `\n[browser pageerror] ${error.stack || error.message}`; });
307
+ page.on('pageerror', (error) => { browserErrors.push(error.message); serverOutput += `\n[browser pageerror] ${error.stack || error.message}`; });
308
+ await page.setViewport({ width: 1440, height: 1000 });
259
309
  page.setDefaultTimeout(30_000);
260
310
  await page.goto(urlResult.url, { waitUntil: 'networkidle2', timeout: 30_000 });
261
311
  // A fresh DSH profile may show the product's first-run disclosure before
262
312
  // the composer is mounted. Dismiss that visible UI gate through its real
263
313
  // button; do not seed cookies or attach an existing browser profile.
264
314
  await page.evaluate(() => {
265
- const button = [...document.querySelectorAll('button')].find(node => node.textContent?.trim() === '继续');
315
+ const button = [...document.querySelectorAll('button')].find(node => /^(继续|Continue)$/.test(node.textContent?.trim() || ''));
266
316
  button?.click();
267
317
  });
268
318
  // Register the isolated fixture through DSH's authenticated public RPC
@@ -291,25 +341,28 @@ document.documentElement.setAttribute('data-${htmlSentinel}', 'host-ran-it');
291
341
  // host-authoritative registration through its normal baseline path.
292
342
  await page.reload({ waitUntil: 'networkidle2', timeout: 30_000 });
293
343
  await page.evaluate(() => {
294
- const button = [...document.querySelectorAll('button')].find(node => node.textContent?.trim() === '继续');
344
+ const button = [...document.querySelectorAll('button')].find(node => /^(继续|Continue)$/.test(node.textContent?.trim() || ''));
295
345
  button?.click();
296
346
  });
297
347
  await page.waitForFunction((title) => document.body.innerText.includes(title), { timeout: 30_000 }, workspaceTitle);
298
- await page.click('button[aria-label="选择工作区"], button[aria-label="Select workspace"]');
299
- await page.waitForFunction((title) => [...document.querySelectorAll('[role="menuitem"], [role="option"], button')].some(node => node.textContent?.trim() === title), { timeout: 30_000 }, workspaceTitle);
348
+ await page.click('button[aria-label="选择工作区"], button[aria-label="Choose workspace"], button[aria-label="Select workspace"]');
349
+ await page.waitForFunction((title) => [...document.querySelectorAll('[role="menuitem"], [role="option"]')].some(node => node.textContent?.trim() === title), { timeout: 30_000 }, workspaceTitle);
300
350
  await page.evaluate((title) => {
301
- const node = [...document.querySelectorAll('[role="menuitem"], [role="option"], button')].find(candidate => candidate.textContent?.trim() === title);
351
+ const node = [...document.querySelectorAll('[role="menuitem"], [role="option"]')].find(candidate => candidate.textContent?.trim() === title);
302
352
  node?.click();
303
353
  }, workspaceTitle);
354
+ await page.waitForFunction((title) => [...document.querySelectorAll('button[aria-label="选择工作区"], button[aria-label="Choose workspace"], button[aria-label="Select workspace"]')]
355
+ .some(node => node.textContent?.includes(title)), { timeout: 30_000 }, workspaceTitle);
304
356
  await page.waitForSelector('[contenteditable="true"]');
357
+ console.log('Web E2E: isolated installed package and selected workspace ready');
305
358
  const submit = async (text) => {
306
- const editor = await page.$('[contenteditable="true"]');
307
- assert.ok(editor, 'real composer missing');
308
- await editor.click();
309
- await page.keyboard.type(text);
310
- const button = await page.$('button[aria-label="发送消息"], button[aria-label="Send message"]');
311
- assert.ok(button, 'real composer submit button missing');
312
- await button.click();
359
+ await page.locator('[contenteditable="true"]').fill(text);
360
+ await page.waitForFunction(() => {
361
+ const button = document.querySelector('button[aria-label="发送消息"], button[aria-label="Send message"]');
362
+ return button && !button.disabled;
363
+ });
364
+ await page.locator('button[aria-label="发送消息"], button[aria-label="Send message"]').click();
365
+ await page.waitForFunction((message) => document.body.innerText.includes(message), { timeout: 30_000 }, text);
313
366
  };
314
367
  await submit('请列出并打开刚才生成的 trip-2027.md 行程产物。');
315
368
  const revealArtifactCalls = async () => {
@@ -319,19 +372,29 @@ document.documentElement.setAttribute('data-${htmlSentinel}', 'host-ran-it');
319
372
  process.click();
320
373
  }
321
374
  });
375
+ await page.waitForFunction(() => [...document.querySelectorAll('[data-turn-process]')]
376
+ .every(node => node.disabled || node.getAttribute('aria-expanded') === 'true'));
377
+ await page.evaluate(() => {
378
+ for (const activity of document.querySelectorAll('[data-process-activity]')) {
379
+ if (activity.getAttribute('aria-expanded') !== 'true')
380
+ activity.click();
381
+ }
382
+ });
322
383
  };
323
384
  await page.waitForFunction(() => document.querySelector('[data-turn-process="1"]') !== null, { timeout: 60_000 });
385
+ await page.waitForFunction(() => document.body.innerText.includes('已展示行程产物。'), { timeout: 60_000 });
324
386
  await revealArtifactCalls();
325
387
  await page.waitForSelector('[data-gotry-artifact-card="list"]');
326
388
  await page.waitForSelector('[data-gotry-artifact-card="list"][data-gotry-artifact-state="ok"]');
327
389
  await page.waitForSelector('[data-gotry-artifact-card="read"] [data-gotry-artifact-line="1"]');
390
+ console.log('Web E2E: native tool cards visible');
328
391
  assert.equal(await page.$eval('[data-gotry-artifact-card="list"]', (node) => {
329
392
  const rect = node.getBoundingClientRect();
330
- return rect.width > 0 && rect.height > 0;
393
+ return !node.closest('[hidden]') && rect.width > 0 && rect.height > 0;
331
394
  }), true, 'artifact list card is not visibly rendered');
332
395
  assert.equal(await page.$eval('[data-gotry-artifact-card="read"]', (node) => {
333
396
  const rect = node.getBoundingClientRect();
334
- return rect.width > 0 && rect.height > 0;
397
+ return !node.closest('[hidden]') && rect.width > 0 && rect.height > 0;
335
398
  }), true, 'artifact read card is not visibly rendered');
336
399
  const pathButton = await page.$('[data-gotry-artifact-path$="trip-2027.md"]');
337
400
  assert.ok(pathButton, 'list card path button missing');
@@ -353,14 +416,15 @@ document.documentElement.setAttribute('data-${htmlSentinel}', 'host-ran-it');
353
416
  assertions.firstTurn = firstSnapshot;
354
417
  await submit('请修改刚才的行程产物,在末尾追加 Day 6,然后重新读取并展示更新版本。');
355
418
  await page.waitForFunction(() => document.querySelector('[data-turn-process="2"]') !== null, { timeout: 60_000 });
419
+ await page.waitForFunction(() => document.body.innerText.includes('已更新并重新读取行程产物。'), { timeout: 60_000 });
356
420
  await revealArtifactCalls();
357
421
  await page.waitForFunction(() => document.querySelectorAll('[data-gotry-artifact-card="read"]').length >= 2, { timeout: 60_000 });
358
422
  await page.waitForFunction(() => [...document.querySelectorAll('[data-gotry-artifact-card="read"]')].some(node => node.textContent?.includes('Day 6: revision requested')), { timeout: 60_000 });
359
- // The artifact card has the fresh read result, but the public DSH file
360
- // preview intentionally keeps its old page until the user acknowledges
361
- // the changed-file banner. Exercise that visible reload control instead
362
- // of treating the custom card or hidden DOM as sidebar evidence.
363
- await page.waitForSelector('[data-textpreview-changed]', { timeout: 60_000 });
423
+ // Independently verify the native file preview. The current Host may
424
+ // refresh automatically; a retained older page must expose a reload
425
+ // control. In either case the rendered sidebar must show the new line.
426
+ await page.waitForFunction(() => document.querySelector('[data-textpreview-changed]')
427
+ || document.querySelector('[data-textpreview-body]')?.textContent?.includes('Day 6: revision requested'), { timeout: 60_000 });
364
428
  const staleRightPreview = await page.$eval('[data-textpreview-state="text"]', (node) => {
365
429
  const rect = node.getBoundingClientRect();
366
430
  const body = node.querySelector('[data-textpreview-body]');
@@ -371,21 +435,19 @@ document.documentElement.setAttribute('data-${htmlSentinel}', 'host-ran-it');
371
435
  };
372
436
  });
373
437
  assert.equal(staleRightPreview.visible, true, 'right file preview is not visibly rendered before reload');
374
- assert.equal(staleRightPreview.changedBanner, true, 'right file preview did not show the changed-file banner');
375
- assert.equal(staleRightPreview.includesDay6, false, 'right file preview unexpectedly contained Day 6 before reload');
376
- const reloadButton = await page.$('[data-textpreview-changed] [data-textpreview-reload-now]');
377
- assert.ok(reloadButton, 'right file preview reload control missing');
378
- const reloadControl = await page.$eval('[data-textpreview-changed] [data-textpreview-reload-now]', (node) => {
379
- const rect = node.getBoundingClientRect();
380
- return {
381
- visible: rect.width > 0 && rect.height > 0,
382
- text: node.textContent?.trim() || '',
383
- ariaLabel: node.getAttribute('aria-label'),
384
- };
385
- });
386
- assert.equal(reloadControl.visible, true, 'right file preview reload control is not visible');
387
- assert.match(reloadControl.text, /重新载入|Reload/);
388
- await reloadButton.click();
438
+ let reloadControl = { visible: false, text: 'Automatic native refresh', ariaLabel: null };
439
+ if (!staleRightPreview.includesDay6) {
440
+ assert.equal(staleRightPreview.changedBanner, true, 'a stale file preview must expose its changed-file banner');
441
+ const reloadButton = await page.$('[data-textpreview-changed] [data-textpreview-reload-now]');
442
+ assert.ok(reloadButton, 'right file preview reload control missing');
443
+ reloadControl = await page.$eval('[data-textpreview-changed] [data-textpreview-reload-now]', (node) => {
444
+ const rect = node.getBoundingClientRect();
445
+ return { visible: rect.width > 0 && rect.height > 0, text: node.textContent?.trim() || '', ariaLabel: node.getAttribute('aria-label') };
446
+ });
447
+ assert.equal(reloadControl.visible, true, 'right file preview reload control is not visible');
448
+ assert.match(reloadControl.text, /重新载入|Reload/);
449
+ await reloadButton.click();
450
+ }
389
451
  await page.waitForFunction(() => {
390
452
  const preview = document.querySelector('[data-textpreview-state="text"]');
391
453
  const body = preview?.querySelector('[data-textpreview-body]');
@@ -477,6 +539,7 @@ document.documentElement.setAttribute('data-${htmlSentinel}', 'host-ran-it');
477
539
  // terms, not against hidden/animating geometry.
478
540
  await submit('请用 gotry_artifacts_list 列出当前工作区里 HTML 产物,然后用 gotry_artifacts_read 读取 trip-2027.html 的源码展示给用户。');
479
541
  await page.waitForFunction(() => document.querySelector('[data-turn-process="3"]') !== null, { timeout: 60_000 });
542
+ await page.waitForFunction(() => document.body.innerText.includes('已读取 HTML 产物'), { timeout: 60_000 });
480
543
  await revealArtifactCalls();
481
544
  await page.waitForFunction(() => [...document.querySelectorAll('[data-gotry-artifact-card="read"]')].some(node => node.getAttribute('data-gotry-artifact-source') === 'html'), { timeout: 60_000 });
482
545
  const htmlSourceSnapshot = await page.evaluate((sentinel) => {
@@ -628,6 +691,7 @@ document.documentElement.setAttribute('data-${htmlSentinel}', 'host-ran-it');
628
691
  screenshots: { page: htmlScreenshotPath, iframe: htmlIframeScreenshotPath },
629
692
  htmlFixture: { path: join(workspaceDir, 'trip-2027.html'), sentinel: htmlSentinel },
630
693
  };
694
+ console.log('Web E2E: read/edit/refresh and native HTML sandbox passed');
631
695
  assert.deepEqual(relay.servedTools, [
632
696
  'gotry_artifacts_list',
633
697
  'gotry_artifacts_read',
@@ -637,6 +701,102 @@ document.documentElement.setAttribute('data-${htmlSentinel}', 'host-ran-it');
637
701
  'gotry_artifacts_list',
638
702
  'gotry_artifacts_read',
639
703
  ]);
704
+ // The render tool emits the same durable presentation event as native
705
+ // present. No extra model call or invented Client-only card is involved.
706
+ await submit('请生成一个未核验的 HTML 行程文件,验证原生文件交付。');
707
+ await page.waitForFunction(() => document.body.innerText.includes('原生文件交付已生成'), { timeout: 60_000 });
708
+ await page.waitForSelector('[data-presented-file] button[title$="gotry-itinerary-native-delivery.html"]');
709
+ assert.ok(existsSync(join(workspaceDir, 'gotry-itinerary-native-delivery.html')));
710
+ await page.click('[data-presented-file] button[title$="gotry-itinerary-native-delivery.html"]');
711
+ const generatedFrame = await page.waitForFrame(async (frame) => {
712
+ if (!frame.url().startsWith('blob:'))
713
+ return false;
714
+ return frame.evaluate(() => document.querySelector('h1')?.textContent === 'Native delivery fixture').catch(() => false);
715
+ }, { timeout: 30_000 });
716
+ assert.ok(generatedFrame, 'native delivery card did not open the generated file');
717
+ await page.screenshot({ path: join(outputDir, 'gotry-native-delivery.png'), fullPage: false });
718
+ assertions.delivery = { nativeCard: true, nativePreview: true, filename: 'gotry-itinerary-native-delivery.html' };
719
+ // Search and paginate the persistent workbench without asking the model.
720
+ for (let index = 0; index < 22; index++)
721
+ writeFileSync(join(workspaceDir, `history-${String(index).padStart(2, '0')}.md`), '# History fixture\n');
722
+ const modelRequestsBeforeBrowse = relay.bodies.length;
723
+ const clickText = async (pattern, root = 'body') => {
724
+ await page.waitForFunction((source, selector) => [...document.querySelectorAll(selector + ' button')].some(node => {
725
+ const rect = node.getBoundingClientRect();
726
+ return rect.width > 0 && rect.height > 0 && !node.disabled
727
+ && new RegExp(source).test(node.textContent?.trim() || node.getAttribute('aria-label') || '');
728
+ }), { timeout: 30_000 }, pattern, root);
729
+ await page.evaluate((source, selector) => {
730
+ const node = [...document.querySelectorAll(selector + ' button')].find(node => {
731
+ const rect = node.getBoundingClientRect();
732
+ return rect.width > 0 && rect.height > 0 && !node.disabled
733
+ && new RegExp(source).test(node.textContent?.trim() || node.getAttribute('aria-label') || '');
734
+ });
735
+ node.click();
736
+ }, pattern, root);
737
+ };
738
+ await page.click('button[aria-label="新标签页"], button[aria-label="New tab"]');
739
+ await clickText('^GoTry (产物|artifacts)', '[data-sidebar-right-guide]');
740
+ await page.waitForSelector('[data-gotry-artifact-entry]');
741
+ assert.equal(await page.$$eval('[data-gotry-artifact-entry]', (nodes) => nodes.length), 20);
742
+ await clickText('^(下一页|Next)$', '[data-gotry-artifacts-browser]');
743
+ await page.waitForFunction(() => document.querySelector('[data-gotry-artifacts-browser] [role="status"]')?.textContent?.startsWith('21–'));
744
+ const search = '[data-gotry-artifacts-browser] input';
745
+ await page.type(search, 'trip-2027');
746
+ await page.waitForFunction(() => document.querySelectorAll('[data-gotry-artifact-entry]').length === 2);
747
+ await page.screenshot({ path: join(outputDir, 'gotry-artifacts-workbench.png'), fullPage: false });
748
+ await page.click('[data-gotry-artifact-entry$="trip-2027.html"]');
749
+ const historyFrame = await page.waitForFrame(async (frame) => {
750
+ if (!frame.url().startsWith('blob:'))
751
+ return false;
752
+ return frame.evaluate(() => document.querySelector('#artifact-web-h1')?.textContent === 'GoTry artifact-web visible heading').catch(() => false);
753
+ }, { timeout: 30_000 });
754
+ assert.ok(historyFrame, 'persistent workbench failed to open HTML in the native preview');
755
+ assert.equal(relay.bodies.length, modelRequestsBeforeBrowse, 'history browsing must not invoke the model');
756
+ assertions.workbench = { entryVisible: true, pageSize: 20, pagination: true, searchMatches: 2, nativePreview: true, modelRequests: 0 };
757
+ // Native Plugins navigation and real official-file persistence.
758
+ await clickText('^(插件|Plugins)$');
759
+ await page.waitForSelector('[data-plugin-item="gotry"] button');
760
+ await page.click('[data-plugin-item="gotry"] button');
761
+ await page.waitForFunction(() => /匿名试用|Anonymous trial/.test(document.querySelector('[data-gotry-flyai-status]')?.textContent || ''));
762
+ assert.equal(await page.$eval('#gotry-flyai-key', (node) => node.type), 'password');
763
+ const goodKey = 'sk-web-good-candidate-1234', badKey = 'sk-web-bad-candidate';
764
+ await page.type('#gotry-flyai-key', goodKey);
765
+ await clickText('^(验证并保存|Verify and save)$', '[data-gotry-flyai-settings]');
766
+ await page.waitForFunction(() => /当前配置已验证|Current configuration verified/.test(document.querySelector('[data-gotry-flyai-status]')?.textContent || ''));
767
+ const configPath = join(homeDir, '.flyai/config.json'), beforeBad = readFileSync(configPath, 'utf8');
768
+ assert.equal(JSON.parse(beforeBad).FLYAI_API_KEY, goodKey);
769
+ assert.equal(statSync(configPath).mode & 0o777, 0o600);
770
+ assert.equal(await page.$eval('#gotry-flyai-key', (node) => node.value), '');
771
+ await page.screenshot({ path: join(outputDir, 'gotry-flyai-settings.png'), fullPage: false });
772
+ await page.type('#gotry-flyai-key', badKey);
773
+ await clickText('^(验证并保存|Verify and save)$', '[data-gotry-flyai-settings]');
774
+ await page.waitForFunction(() => /401/.test(document.querySelector('[data-gotry-flyai-settings] [role="alert"]')?.textContent || ''));
775
+ assert.equal(readFileSync(configPath, 'utf8'), beforeBad);
776
+ assert.equal(await page.$eval('[data-gotry-flyai-status]', (node) => /已验证|verified/.test(node.textContent || '')), true);
777
+ const leakedKey = await page.evaluate((keys) => keys.some(key => document.documentElement.outerHTML.includes(key)), [goodKey, badKey]);
778
+ assert.equal(leakedKey, false, 'a saved or rejected credential leaked into the rendered document');
779
+ assert.equal(serverOutput.includes(goodKey) || serverOutput.includes(badKey), false, 'a credential leaked into the runtime/browser log');
780
+ assert.equal(JSON.stringify(relay.bodies).includes(goodKey) || JSON.stringify(relay.bodies).includes(badKey), false, 'a credential entered model traffic');
781
+ await page.screenshot({ path: join(outputDir, 'gotry-flyai-rejected.png'), fullPage: false });
782
+ await page.setViewport({ width: 390, height: 844 });
783
+ await page.waitForFunction(() => (document.querySelector('[data-gotry-flyai-settings]')?.getBoundingClientRect().width || 0) >= 240);
784
+ const layout = await page.$eval('[data-gotry-flyai-settings]', (node) => ({ width: node.getBoundingClientRect().width, scrollWidth: node.scrollWidth }));
785
+ assert.ok(layout.scrollWidth <= layout.width + 1, `configuration fields overflow the narrow viewport: ${JSON.stringify(layout)}`);
786
+ await page.screenshot({ path: join(outputDir, 'gotry-flyai-narrow.png'), fullPage: false });
787
+ await page.setViewport({ width: 1440, height: 1000 });
788
+ await clickText('^(重新读取状态|Refresh status)$', '[data-gotry-flyai-settings]');
789
+ await page.waitForFunction(() => /已验证|verified/.test(document.querySelector('[data-gotry-flyai-status]')?.textContent || ''));
790
+ const origin = new URL(urlResult.url).origin;
791
+ assert.equal((await fetch(origin + '/api/gotry/flyai')).status, 401, 'configuration route bypassed native browser authentication');
792
+ assert.equal((await fetch(origin + '/api/gotry/flyai', { headers: { origin: 'https://untrusted.example' } })).status, 403, 'configuration route bypassed native Origin checks');
793
+ await clickText('^(清除本机 Key|Clear local key)$', '[data-gotry-flyai-settings]');
794
+ await page.waitForFunction(() => /匿名试用|Anonymous trial/.test(document.querySelector('[data-gotry-flyai-status]')?.textContent || ''));
795
+ assertions.flyai = { nativePluginsPage: true, maskedInput: true, verifiedSave: true, officialConfigMode: '0600',
796
+ rejectedCandidatePreservesOldKey: true, noDomLogModelSecret: true, refresh: true, clear: true,
797
+ unauthenticatedStatus: 401, crossOriginStatus: 403, narrowViewport: { width: 390, height: 844, ...layout }, verifier: 'isolated CLI fixture' };
798
+ assert.deepEqual(browserErrors, [], 'browser reported uncaught application errors');
799
+ assertions.browserErrors = browserErrors;
640
800
  assertions.relay = { toolCalls: relay.servedTools, bodyCount: relay.bodies.length };
641
801
  const screenshotPath = join(outputDir, 'artifact-web-e2e.png');
642
802
  await page.screenshot({ path: screenshotPath, fullPage: true });
@@ -647,7 +807,7 @@ document.documentElement.setAttribute('data-${htmlSentinel}', 'host-ran-it');
647
807
  const receipt = { screenshot: { path: screenshotPath, sha256: screenshotSha256 }, domAssertions: { path: domPath, sha256: domSha256 }, serverOutputSha256: createHash('sha256').update(serverOutput).digest('hex') };
648
808
  writeFileSync(join(outputDir, 'artifact-web-e2e.receipt.json'), JSON.stringify(receipt, null, 2) + '\n', { mode: 0o600 });
649
809
  passed = true;
650
- console.log(`DSH ARTIFACT WEB E2E: PASS fresh Chrome profile, list→select/open→preview→read→edit→updated read; screenshot=${screenshotPath}; screenshotSha256=${screenshotSha256}; dom=${domPath}; domSha256=${domSha256}`);
810
+ console.log(`DSH ARTIFACT WEB E2E: PASS fresh installed package; native configuration save/reject/clear, authenticated routes, persistent workbench search/page/preview, native generated-file delivery, list/read/edit/refresh; screenshot=${screenshotPath}; screenshotSha256=${screenshotSha256}; dom=${domPath}; domSha256=${domSha256}`);
651
811
  }
652
812
  finally {
653
813
  if (page && !passed) {
@@ -657,6 +817,7 @@ document.documentElement.setAttribute('data-${htmlSentinel}', 'host-ran-it');
657
817
  writeFileSync(failureHtml, await page.content(), { mode: 0o600 });
658
818
  await page.screenshot({ path: failurePng, fullPage: true });
659
819
  writeFileSync(join(outputDir, 'artifact-web-e2e.failure.log'), serverOutput, { mode: 0o600 });
820
+ writeFileSync(join(outputDir, 'artifact-web-e2e.failure.relay.json'), JSON.stringify({ servedTools: relay.servedTools, bodies: relay.bodies }, null, 2), { mode: 0o600 });
660
821
  console.error(`DSH ARTIFACT WEB E2E failure artifacts: html=${failureHtml}; screenshot=${failurePng}; log=${join(outputDir, 'artifact-web-e2e.failure.log')}`);
661
822
  }
662
823
  catch { /* browser may have failed before a document existed */ }