@danceiny/gotry 0.2.0-rc.30 → 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.
@@ -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 */ }
@@ -5,7 +5,7 @@
5
5
  * 也不会请求供应商 API。覆盖 setup 写入、权限、回执绑定、脱敏及失败回滚。
6
6
  */
7
7
  import assert from 'node:assert/strict';
8
- import { spawnSync } from 'node:child_process';
8
+ import { spawn, spawnSync } from 'node:child_process';
9
9
  import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
10
10
  import { tmpdir } from 'node:os';
11
11
  import { join } from 'node:path';
@@ -39,7 +39,7 @@ chmodSync(fakeVerifier, 0o700);
39
39
  // that the npm package exposes for `gotry setup ...` dispatch.
40
40
  const packageFixture = join(sandbox, 'package-shaped');
41
41
  mkdirSync(join(packageFixture, 'bin'), { recursive: true });
42
- for (const file of ['gotry-inner.js', 'gotry-bootstrap.js', 'gotry-process-liveness.js', 'gotry-runtime-resolution.js']) {
42
+ for (const file of ['gotry-inner.js', 'gotry-bootstrap.js', 'gotry-flyai-verification.js', 'gotry-extension-status.js', 'gotry-process-liveness.js', 'gotry-runtime-resolution.js']) {
43
43
  copyFileSync(join(repoRoot, 'bin', file), join(packageFixture, 'bin', file));
44
44
  }
45
45
  writeFileSync(join(packageFixture, 'package.json'), JSON.stringify({ type: 'module', version: 'test' }) + '\n');
@@ -226,3 +226,76 @@ for (const failure of ['leak-403', 'leak-exit']) {
226
226
  assert.deepEqual(readFileSync(configPath(verifyFailHome)), verifyBefore);
227
227
  }
228
228
  console.log('6. upstream stderr cannot leak candidate credentials or endpoint secrets OK');
229
+ // A parent-only interrupt must reap the private verifier group before setup
230
+ // exits. A grandchild holds both pipes to expose orphaned verification jobs.
231
+ if (process.platform !== 'win32') {
232
+ const hangingVerifier = join(sandbox, 'hanging-flyai-cli.cjs');
233
+ writeFileSync(hangingVerifier, `#!/usr/bin/env node
234
+ const { spawn } = require('node:child_process')
235
+ const { writeFileSync } = require('node:fs')
236
+ const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'inherit' })
237
+ writeFileSync(process.env.FAKE_FLYAI_PID_FILE, JSON.stringify([process.pid, child.pid]))
238
+ setInterval(() => {}, 1000)
239
+ `);
240
+ chmodSync(hangingVerifier, 0o700);
241
+ const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
242
+ const alive = (pid) => { try {
243
+ process.kill(pid, 0);
244
+ return true;
245
+ }
246
+ catch {
247
+ return false;
248
+ } };
249
+ for (const signal of ['SIGINT', 'SIGTERM']) {
250
+ const home = mkdtempSync(join(sandbox, `cancel-${signal}-`));
251
+ writeConfig(home, { FLYAI_API_KEY: oldKey, keep: 'yes' });
252
+ const before = readFileSync(configPath(home));
253
+ const pidFile = join(home, 'verifier-pids.json');
254
+ const child = spawn('node', [bootstrap, 'setup', 'flyai', '--stdin'], {
255
+ cwd: sandbox,
256
+ env: baseEnv(home, { GOTRY_FLYAI_CLI_BIN: hangingVerifier, FAKE_FLYAI_PID_FILE: pidFile, GOTRY_FLYAI_VERIFY_TIMEOUT_MS: '60000' }),
257
+ stdio: ['pipe', 'pipe', 'pipe'],
258
+ });
259
+ let output = '', pids = [];
260
+ child.stdout.on('data', chunk => { output += chunk; });
261
+ child.stderr.on('data', chunk => { output += chunk; });
262
+ const closed = new Promise(resolve => child.once('close', (code, signal) => resolve({ code, signal })));
263
+ child.stdin.end(`${key}\n`);
264
+ try {
265
+ const startDeadline = Date.now() + 5000;
266
+ while (!existsSync(pidFile) && Date.now() < startDeadline)
267
+ await delay(20);
268
+ assert.ok(existsSync(pidFile), 'verifier must start before the interrupt');
269
+ pids = JSON.parse(readFileSync(pidFile, 'utf8'));
270
+ child.kill(signal);
271
+ let bound;
272
+ const result = await Promise.race([closed, new Promise((_, reject) => {
273
+ bound = setTimeout(() => reject(new Error('interrupted setup did not exit within 5s')), 5000);
274
+ })]).finally(() => clearTimeout(bound));
275
+ assert.ok(result.signal === signal || result.code === 128 + (signal === 'SIGINT' ? 2 : 15), JSON.stringify(result));
276
+ const cleanupDeadline = Date.now() + 1000;
277
+ while (pids.some(alive) && Date.now() < cleanupDeadline)
278
+ await delay(20);
279
+ assert.ok(pids.every(pid => !alive(pid)), `${signal} left a verifier descendant alive`);
280
+ assert.deepEqual(readFileSync(configPath(home)), before);
281
+ assert.equal(existsSync(receiptPath(home)), false);
282
+ assert.ok(!output.includes(key), 'interrupt output must not echo the candidate');
283
+ }
284
+ finally {
285
+ child.kill('SIGKILL');
286
+ if (pids[0]) {
287
+ try {
288
+ process.kill(-pids[0], 'SIGKILL');
289
+ }
290
+ catch { /* already gone */ }
291
+ }
292
+ for (const pid of pids) {
293
+ try {
294
+ process.kill(pid, 'SIGKILL');
295
+ }
296
+ catch { /* already gone */ }
297
+ }
298
+ }
299
+ }
300
+ console.log('7. SIGINT/SIGTERM reap the verifier group and preserve old credentials OK');
301
+ }