@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.
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/bin/gotry-bootstrap.js +56 -83
- package/bin/gotry-extension-status.d.ts +12 -0
- package/bin/gotry-extension-status.js +113 -0
- package/bin/gotry-flyai-verification.d.ts +9 -0
- package/bin/gotry-flyai-verification.js +63 -0
- package/bin/gotry-inner.js +2 -1
- package/client/client.js +183 -0
- package/dist/capabilities/doctor.js +7 -15
- package/dist/scripts/artifact-client-contract-tests.js +4 -3
- package/dist/scripts/artifact-delivery-tests.js +31 -0
- package/dist/scripts/bootstrap-tests.js +14 -4
- package/dist/scripts/doctor-tests.js +36 -7
- package/dist/scripts/dsh-artifact-web-e2e.js +204 -43
- package/dist/scripts/flyai-setup-tests.js +75 -2
- package/dist/scripts/gotry-web-api-tests.js +114 -0
- package/dist/scripts/gotry-web-client-tests.js +28 -0
- package/dist/scripts/issue-271-liveness-tests.js +3 -1
- package/dist/src/artifact-delivery.js +26 -0
- package/dist/src/gotry-web-api.js +157 -0
- package/dist/src/index.js +6 -0
- package/extension/manifest.json +2 -2
- package/package.json +7 -2
- package/ts/src/artifact-delivery.ts +25 -0
- package/ts/src/gotry-web-api.ts +140 -0
- package/ts/src/index.ts +6 -0
|
@@ -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
|
|
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' ||
|
|
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,
|
|
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"]
|
|
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"]
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
await
|
|
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
|
-
//
|
|
360
|
-
//
|
|
361
|
-
// the
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
visible: rect.width > 0 && rect.height > 0,
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
|
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
|
+
}
|