dsh-better-display 0.3.8 → 0.3.9
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/CHANGELOG.md +11 -0
- package/DESIGN.md +43 -3
- package/README.en.md +9 -1
- package/README.md +9 -1
- package/docs/dependency-update-policy.md +9 -0
- package/docs/extension-points.md +172 -0
- package/docs/official-rendering-bridge.md +48 -19
- package/docs/openapi.yaml +147 -0
- package/docs/runbooks/blank-reader.md +39 -0
- package/lib/client.js +1417 -490
- package/lib/client.js.map +1 -1
- package/lib/dsh-better-display.js +378 -144
- package/lib/types/client/AssistantNode.d.ts +20 -0
- package/lib/types/client/AssistantNode.d.ts.map +1 -0
- package/lib/types/client/AssistantNode.js +46 -0
- package/lib/types/client/AssistantNode.js.map +1 -0
- package/lib/types/client/Blocks.d.ts.map +1 -1
- package/lib/types/client/Blocks.js +33 -10
- package/lib/types/client/Blocks.js.map +1 -1
- package/lib/types/client/ChoreographedFlow.d.ts.map +1 -1
- package/lib/types/client/ChoreographedFlow.js +2 -0
- package/lib/types/client/ChoreographedFlow.js.map +1 -1
- package/lib/types/client/ClosedProcessSummary.d.ts.map +1 -1
- package/lib/types/client/ClosedProcessSummary.js +3 -1
- package/lib/types/client/ClosedProcessSummary.js.map +1 -1
- package/lib/types/client/McpAppFrame.d.ts.map +1 -1
- package/lib/types/client/McpAppFrame.js +5 -11
- package/lib/types/client/McpAppFrame.js.map +1 -1
- package/lib/types/client/OfficialContent.d.ts +17 -3
- package/lib/types/client/OfficialContent.d.ts.map +1 -1
- package/lib/types/client/OfficialContent.js +33 -7
- package/lib/types/client/OfficialContent.js.map +1 -1
- package/lib/types/client/Reader.d.ts.map +1 -1
- package/lib/types/client/Reader.js +43 -71
- package/lib/types/client/Reader.js.map +1 -1
- package/lib/types/client/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/SettingsSection.js +0 -5
- package/lib/types/client/SettingsSection.js.map +1 -1
- package/lib/types/client/StickyLane.d.ts +12 -0
- package/lib/types/client/StickyLane.d.ts.map +1 -1
- package/lib/types/client/StickyLane.js +78 -2
- package/lib/types/client/StickyLane.js.map +1 -1
- package/lib/types/client/ToolActivity.js.map +1 -1
- package/lib/types/client/index.d.ts +3 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +6 -0
- package/lib/types/client/index.js.map +1 -1
- package/lib/types/client/live-turn.d.ts +17 -5
- package/lib/types/client/live-turn.d.ts.map +1 -1
- package/lib/types/client/live-turn.js +106 -76
- package/lib/types/client/live-turn.js.map +1 -1
- package/lib/types/client/motion.d.ts +26 -2
- package/lib/types/client/motion.d.ts.map +1 -1
- package/lib/types/client/motion.js +136 -10
- package/lib/types/client/motion.js.map +1 -1
- package/lib/types/client/official-slots.d.ts +9 -0
- package/lib/types/client/official-slots.d.ts.map +1 -1
- package/lib/types/client/official-slots.js +22 -7
- package/lib/types/client/official-slots.js.map +1 -1
- package/lib/types/client/projection.d.ts +15 -2
- package/lib/types/client/projection.d.ts.map +1 -1
- package/lib/types/client/projection.js +16 -4
- package/lib/types/client/projection.js.map +1 -1
- package/lib/types/client/rewind-cut.d.ts +131 -0
- package/lib/types/client/rewind-cut.d.ts.map +1 -0
- package/lib/types/client/rewind-cut.js +321 -0
- package/lib/types/client/rewind-cut.js.map +1 -0
- package/lib/types/client/text-transform.d.ts +93 -0
- package/lib/types/client/text-transform.d.ts.map +1 -0
- package/lib/types/client/text-transform.js +183 -0
- package/lib/types/client/text-transform.js.map +1 -0
- package/lib/types/client/timeline.d.ts.map +1 -1
- package/lib/types/client/timeline.js.map +1 -1
- package/lib/types/client/tool-activity.d.ts +9 -2
- package/lib/types/client/tool-activity.d.ts.map +1 -1
- package/lib/types/client/tool-activity.js +42 -2
- package/lib/types/client/tool-activity.js.map +1 -1
- package/lib/types/client/turn-slots.d.ts +29 -0
- package/lib/types/client/turn-slots.d.ts.map +1 -0
- package/lib/types/client/turn-slots.js +62 -0
- package/lib/types/client/turn-slots.js.map +1 -0
- package/lib/types/client/types.d.ts +18 -0
- package/lib/types/client/types.d.ts.map +1 -1
- package/lib/types/dsh-better-display.d.ts +4 -1
- package/lib/types/dsh-better-display.d.ts.map +1 -1
- package/lib/types/dsh-better-display.js +56 -7
- package/lib/types/dsh-better-display.js.map +1 -1
- package/lib/types/flags.d.ts +7 -0
- package/lib/types/flags.d.ts.map +1 -0
- package/lib/types/flags.js +37 -0
- package/lib/types/flags.js.map +1 -0
- package/lib/types/log.d.ts +17 -0
- package/lib/types/log.d.ts.map +1 -0
- package/lib/types/log.js +122 -0
- package/lib/types/log.js.map +1 -0
- package/lib/types/metrics.d.ts +18 -0
- package/lib/types/metrics.d.ts.map +1 -0
- package/lib/types/metrics.js +35 -0
- package/lib/types/metrics.js.map +1 -0
- package/package.json +36 -5
- package/scripts/changelog.mjs +20 -0
- package/scripts/check-agents-md.mjs +67 -0
- package/scripts/check-bundle-size.mjs +38 -0
- package/scripts/check-dead-flags.mjs +55 -0
- package/scripts/check-todo-debt.mjs +69 -0
- package/scripts/security-audit-report.mjs +92 -0
- package/scripts/verify-commentary-visible.mjs +627 -0
- package/src/client/AssistantNode.tsx +72 -0
- package/src/client/Blocks.tsx +49 -10
- package/src/client/ChoreographedFlow.tsx +3 -1
- package/src/client/ClosedProcessSummary.tsx +3 -1
- package/src/client/McpAppFrame.tsx +5 -13
- package/src/client/OfficialContent.tsx +47 -9
- package/src/client/Reader.module.css +68 -18
- package/src/client/Reader.tsx +36 -76
- package/src/client/SettingsSection.tsx +1 -7
- package/src/client/StickyLane.tsx +81 -2
- package/src/client/ToolActivity.tsx +1 -1
- package/src/client/index.tsx +8 -1
- package/src/client/live-turn.ts +100 -69
- package/src/client/motion.tsx +135 -7
- package/src/client/official-slots.tsx +23 -7
- package/src/client/projection.ts +16 -4
- package/src/client/rewind-cut.ts +340 -0
- package/src/client/text-transform.ts +265 -0
- package/src/client/timeline.ts +0 -2
- package/src/client/tool-activity.ts +49 -2
- package/src/client/turn-slots.ts +80 -0
- package/src/client/types.ts +19 -0
- package/src/dsh-better-display.ts +58 -7
- package/src/flags.ts +40 -0
- package/src/log.ts +137 -0
- package/src/metrics.ts +50 -0
- package/tests/browser-commentary.tsx +191 -0
- package/tests/chat-flow-hook.test.ts +1 -1
- package/tests/cloud-gate.test.ts +1 -1
- package/tests/context-tool-update.test.ts +1 -1
- package/tests/conversation-scroll.test.ts +1 -1
- package/tests/deliverables.test.ts +1 -1
- package/tests/entry.test.ts +1 -1
- package/tests/fixtures/reader-lane-owners.tsx +46 -0
- package/tests/fixtures/reader-official-users.tsx +64 -0
- package/tests/fixtures/reader-theme-overlap.html +80 -0
- package/tests/fixtures/reader-user-bubble.tsx +222 -0
- package/tests/flags.test.ts +66 -0
- package/tests/fold-choreography.test.ts +32 -8
- package/tests/fold-intensity.test.ts +11 -6
- package/tests/footer-precedence.test.ts +1 -1
- package/tests/history-word-regression.test.ts +1 -1
- package/tests/live-turn.test.ts +125 -54
- package/tests/log.test.ts +99 -0
- package/tests/mcp-apps.test.ts +1 -1
- package/tests/message-chrome.test.ts +1 -1
- package/tests/metrics.test.ts +52 -0
- package/tests/official-slots.test.ts +41 -2
- package/tests/open-file.test.ts +1 -1
- package/tests/pending-submission.test.ts +12 -1
- package/tests/pinned-selection.test.ts +16 -0
- package/tests/platform-media.test.ts +1 -1
- package/tests/projection.test.ts +66 -3
- package/tests/reader-glass-css.test.ts +1 -1
- package/tests/reader-official-users.test.ts +11 -1
- package/tests/reader-sticky-surface.test.ts +377 -0
- package/tests/reader-theme-lanes.test.ts +54 -0
- package/tests/reader-theme-overlap.test.ts +151 -0
- package/tests/reader-user-bubble.test.ts +137 -0
- package/tests/reasoning-follow.test.ts +1 -1
- package/tests/retiring-content.test.ts +98 -6
- package/tests/reveal.test.ts +3 -3
- package/tests/rewind-cut.test.ts +1090 -0
- package/tests/settings-copy.test.ts +1 -1
- package/tests/skill-status.test.ts +1 -1
- package/tests/stock-install.test.ts +1 -1
- package/tests/stream-buffer.test.ts +1 -1
- package/tests/styles.test.ts +30 -3
- package/tests/terminal-input-preview.test.ts +1 -1
- package/tests/text-transform.test.ts +272 -0
- package/tests/timeline.test.ts +1 -1
- package/tests/tool-clock.test.ts +1 -1
- package/tests/tool-diff.test.ts +1 -1
- package/tests/waiting-clock.test.ts +1 -1
- package/tests/word-timeline.test.ts +1 -1
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Real-Reader browser regression for the "process report narration" (正文) contract.
|
|
4
|
+
*
|
|
5
|
+
* Contract under test:
|
|
6
|
+
* - Body blocks (assistant text/image/other segments) are ALWAYS visible.
|
|
7
|
+
* - Fold disclosures (top 展开思考与过程 / 本轮过程详情, live fold, ClosedProcessSummary)
|
|
8
|
+
* may only contain reasoning / tool-call / other process records — never body.
|
|
9
|
+
* - Expanding the top disclosure reveals folded reasoning/tool in source order
|
|
10
|
+
* between the body articles; collapsing it keeps the body visible.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* node scripts/verify-commentary-visible.mjs --mode=red # on a pristine (unfixed) checkout
|
|
14
|
+
* node scripts/verify-commentary-visible.mjs --mode=green # on the fixed checkout
|
|
15
|
+
*
|
|
16
|
+
* `red` asserts the bug is reproducible (body folded away). `green` asserts the
|
|
17
|
+
* full contract. Both build tests/browser-commentary.tsx with esbuild (from the
|
|
18
|
+
* tsdown dependency tree), serve it over a local HTTP server and drive the real
|
|
19
|
+
* Reader component with Playwright.
|
|
20
|
+
*/
|
|
21
|
+
import assert from 'node:assert/strict';
|
|
22
|
+
import { execFileSync } from 'node:child_process';
|
|
23
|
+
import { createHash } from 'node:crypto';
|
|
24
|
+
import { existsSync, readdirSync, symlinkSync } from 'node:fs';
|
|
25
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
26
|
+
import { createServer } from 'node:http';
|
|
27
|
+
import { createRequire } from 'node:module';
|
|
28
|
+
import { homedir } from 'node:os';
|
|
29
|
+
import { dirname, join, resolve } from 'node:path';
|
|
30
|
+
import { pathToFileURL } from 'node:url';
|
|
31
|
+
|
|
32
|
+
const root = resolve(import.meta.dirname, '..');
|
|
33
|
+
const args = new Map(process.argv.slice(2).map(argument => {
|
|
34
|
+
const [key, ...rest] = argument.replace(/^--/u, '').split('=');
|
|
35
|
+
return [key, rest.join('=') || 'true'];
|
|
36
|
+
}));
|
|
37
|
+
const mode = args.get('mode') ?? 'green';
|
|
38
|
+
if (mode !== 'red' && mode !== 'green') throw new Error(`Unknown --mode=${mode}`);
|
|
39
|
+
const reducedMotion = args.get('motion') ?? 'reduce';
|
|
40
|
+
|
|
41
|
+
const texts = {
|
|
42
|
+
bodyA: '过程汇报正文A', reasonA: '历史思考:先查简报素材', finalB: '最终回答B', reasonB: '第二次思考:整理结论',
|
|
43
|
+
bodyC: '进行中汇报正文C', reasonC0: '第一轮思考', reasonC1: '第二轮思考',
|
|
44
|
+
bodyD: '失败轮正文D', reasonD: '报错前的思考', bodyE: '中断轮正文E', reasonE: '中断前的思考',
|
|
45
|
+
bodyF: '第一段汇报:', bodyG: '补充汇报:', finalH: '最终回答H', reasonF0: '汇报前的思考', reasonG: '补充说明前的思考',
|
|
46
|
+
toolCallId: 'call-read-1',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// --- dependency plumbing ---------------------------------------------------
|
|
50
|
+
const require = createRequire(join(root, 'package.json'));
|
|
51
|
+
const { build } = await import(pathToFileURL(createRequire(require.resolve('tsx')).resolve('esbuild')).href);
|
|
52
|
+
|
|
53
|
+
function firstExisting(candidates) {
|
|
54
|
+
for (const candidate of candidates) if (candidate && existsSync(candidate)) return candidate;
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Locate the Harness pnpm virtual store that carries the runtime deps the
|
|
59
|
+
* published @deepseek-ai client packages leave external (zustand, immer, shiki…). */
|
|
60
|
+
function locateHarnessPnpm() {
|
|
61
|
+
const candidates = [
|
|
62
|
+
process.env.DSH_PNPM_DIR,
|
|
63
|
+
process.env.DSHX_HARNESS ? join(process.env.DSHX_HARNESS, 'node_modules/.pnpm') : undefined,
|
|
64
|
+
join(homedir(), 'deepseek-harness/node_modules/.pnpm'),
|
|
65
|
+
join(root, '../deepseek-harness/node_modules/.pnpm'),
|
|
66
|
+
join(process.cwd(), 'deepseek-harness/node_modules/.pnpm'),
|
|
67
|
+
];
|
|
68
|
+
for (const candidate of candidates) {
|
|
69
|
+
if (!candidate || !existsSync(candidate)) continue;
|
|
70
|
+
const entries = readdirSync(candidate);
|
|
71
|
+
if (entries.some(entry => entry.startsWith('react-dom@')) && entries.some(entry => entry.startsWith('zustand@'))) return candidate;
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Pick the package directory inside a pnpm virtual store, scoped or not. */
|
|
77
|
+
function pnpmPackage(pnpmDir, name) {
|
|
78
|
+
const flat = name.replace('/', '+');
|
|
79
|
+
const entries = readdirSync(pnpmDir).filter(entry => entry.startsWith(`${flat}@`)).sort();
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
const target = join(pnpmDir, entry, 'node_modules', name);
|
|
82
|
+
if (existsSync(target)) return target;
|
|
83
|
+
}
|
|
84
|
+
throw new Error(`Cannot locate ${name} in Harness pnpm store ${pnpmDir}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const pnpmDir = locateHarnessPnpm();
|
|
88
|
+
if (!pnpmDir) throw new Error('Cannot locate a Harness node_modules/.pnpm store for client runtime deps. Set DSH_PNPM_DIR.');
|
|
89
|
+
|
|
90
|
+
const outDir = join(root, '.evidence/commentary');
|
|
91
|
+
const depsDir = join(outDir, `deps-${mode}`);
|
|
92
|
+
await rm(depsDir, { recursive: true, force: true });
|
|
93
|
+
await mkdir(join(depsDir, 'node_modules/@shikijs'), { recursive: true });
|
|
94
|
+
const sharedDeps = {
|
|
95
|
+
'react-dom': pnpmPackage(pnpmDir, 'react-dom'),
|
|
96
|
+
zustand: pnpmPackage(pnpmDir, 'zustand'),
|
|
97
|
+
immer: pnpmPackage(pnpmDir, 'immer'),
|
|
98
|
+
shiki: pnpmPackage(pnpmDir, 'shiki'),
|
|
99
|
+
'@shikijs/langs': pnpmPackage(pnpmDir, '@shikijs/langs'),
|
|
100
|
+
anser: pnpmPackage(pnpmDir, 'anser'),
|
|
101
|
+
diff: pnpmPackage(pnpmDir, 'diff'),
|
|
102
|
+
};
|
|
103
|
+
for (const [name, target] of Object.entries(sharedDeps)) symlinkSync(target, join(depsDir, 'node_modules', name));
|
|
104
|
+
|
|
105
|
+
// The published primitives bundle imports two leaf modules the desktop app
|
|
106
|
+
// injects at runtime; a minimal stub is enough for rendering and keeps the
|
|
107
|
+
// fixture hermetic (icons / language detection are not under test).
|
|
108
|
+
const stubs = {
|
|
109
|
+
'simple-icons': 'export const siAliexpress={},siApple={},siBaidu={},siBilibili={},siCsdn={},siDuckduckgo={},siEbay={},siFacebook={},siGithub={},siGitlab={},siGoogle={},siInstagram={},siJuejin={},siMdnwebdocs={},siNetflix={},siNpm={},siPypi={},siQq={},siQuora={},siReddit={},siSinaweibo={},siSpotify={},siStackoverflow={},siTaobao={},siTelegram={},siTiktok={},siV2ex={},siWechat={},siWhatsapp={},siWikipedia={},siX={},siYcombinator={},siYoutube={},siZhihu={};',
|
|
110
|
+
'@deepseek-ai/dsh-util-code-language': 'export const CODE_HIGHLIGHT_EXTENSIONS=[];export function languageForPath(){return undefined;}',
|
|
111
|
+
};
|
|
112
|
+
const stubPlugin = {
|
|
113
|
+
name: 'commentary-fixture-stubs',
|
|
114
|
+
setup(buildContext) {
|
|
115
|
+
buildContext.onResolve({ filter: /^(simple-icons|@deepseek-ai\/dsh-util-code-language)$/ }, argument => ({ path: argument.path, namespace: 'commentary-stub' }));
|
|
116
|
+
buildContext.onLoad({ filter: /.*/, namespace: 'commentary-stub' }, argument => ({ contents: stubs[argument.path], loader: 'js' }));
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
await build({
|
|
121
|
+
entryPoints: [join(root, 'tests/browser-commentary.tsx')],
|
|
122
|
+
outfile: join(outDir, 'fixture.js'),
|
|
123
|
+
bundle: true, platform: 'browser', format: 'esm', jsx: 'automatic',
|
|
124
|
+
alias: { react: dirname(require.resolve('react/package.json')) },
|
|
125
|
+
nodePaths: [join(depsDir, 'node_modules')],
|
|
126
|
+
plugins: [stubPlugin],
|
|
127
|
+
define: { 'process.env.NODE_ENV': '"production"' },
|
|
128
|
+
loader: { '.woff2': 'dataurl', '.woff': 'dataurl', '.ttf': 'dataurl', '.svg': 'dataurl' },
|
|
129
|
+
logLevel: 'warning',
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// --- local server ----------------------------------------------------------
|
|
133
|
+
const server = createServer(async (request, response) => {
|
|
134
|
+
const url = request.url ?? '/';
|
|
135
|
+
if (url === '/fixture.js' || url === '/fixture.css') {
|
|
136
|
+
response.setHeader('Content-Type', url.endsWith('.js') ? 'text/javascript' : 'text/css');
|
|
137
|
+
response.end(await readFile(join(outDir, url.slice(1))));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (url === '/favicon.ico') { response.writeHead(204); response.end(); return; }
|
|
141
|
+
if (url !== '/') { response.writeHead(404); response.end(); return; }
|
|
142
|
+
response.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
143
|
+
response.end(`<!doctype html><html lang="zh"><meta charset="utf-8"><title>commentary fixture</title>
|
|
144
|
+
<link rel="stylesheet" href="/fixture.css">
|
|
145
|
+
<style>body{margin:0;font:14px/1.6 system-ui;--dsw-alias-label-primary:#222;--dsw-alias-label-secondary:#666;--dsw-alias-label-tertiary:#888;--dsw-alias-bg-base:#fff;--dsw-alias-border-l2:#ddd}button{cursor:pointer}.evidence-outline{outline:1px solid #f0f0f0}</style>
|
|
146
|
+
<div id="app"></div><script type="module" src="/fixture.js"></script></html>`);
|
|
147
|
+
});
|
|
148
|
+
await new Promise(done => server.listen(0, '127.0.0.1', done));
|
|
149
|
+
const url = `http://127.0.0.1:${server.address().port}/`;
|
|
150
|
+
|
|
151
|
+
// --- playwright ------------------------------------------------------------
|
|
152
|
+
function loadPlaywright() {
|
|
153
|
+
const resolved = (() => {
|
|
154
|
+
try { return require.resolve('playwright'); } catch { /* try playwright-core */ }
|
|
155
|
+
try { return require.resolve('playwright-core'); } catch { /* fall through to explicit paths */ }
|
|
156
|
+
return undefined;
|
|
157
|
+
})();
|
|
158
|
+
const candidates = [
|
|
159
|
+
process.env.PLAYWRIGHT_MODULE,
|
|
160
|
+
resolved,
|
|
161
|
+
join(homedir(), '.dsh/profiles/desktop/node_modules/playwright'),
|
|
162
|
+
join(homedir(), '.dsh/profiles/desktop/node_modules/playwright-core'),
|
|
163
|
+
].filter(Boolean);
|
|
164
|
+
for (const candidate of candidates) {
|
|
165
|
+
if (!existsSync(candidate) && !existsSync(join(String(candidate), 'index.js'))) continue;
|
|
166
|
+
try {
|
|
167
|
+
const loader = createRequire(join(String(candidate), 'index.js'));
|
|
168
|
+
return loader(String(candidate));
|
|
169
|
+
} catch { /* try next */ }
|
|
170
|
+
}
|
|
171
|
+
throw new Error('Playwright is not installed. Set PLAYWRIGHT_MODULE to a playwright/playwright-core directory.');
|
|
172
|
+
}
|
|
173
|
+
const playwright = loadPlaywright();
|
|
174
|
+
const chromium = playwright.chromium;
|
|
175
|
+
|
|
176
|
+
async function launchChromium() {
|
|
177
|
+
const attempts = [
|
|
178
|
+
{ channel: 'chrome', headless: true },
|
|
179
|
+
{ headless: true },
|
|
180
|
+
];
|
|
181
|
+
let lastError;
|
|
182
|
+
for (const options of attempts) {
|
|
183
|
+
try { return await chromium.launch(options); } catch (error) { lastError = error; }
|
|
184
|
+
}
|
|
185
|
+
throw new Error(`Cannot launch Chromium/Chrome: ${lastError?.message ?? lastError}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// --- assertion helpers -----------------------------------------------------
|
|
189
|
+
const failures = [];
|
|
190
|
+
const checks = [];
|
|
191
|
+
let page;
|
|
192
|
+
let errors = [];
|
|
193
|
+
|
|
194
|
+
async function check(name, body) {
|
|
195
|
+
try {
|
|
196
|
+
await body();
|
|
197
|
+
checks.push({ name, passed: true });
|
|
198
|
+
console.log(`PASS ${name}`);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
const message = error instanceof Error ? error.message.split('\n')[0] : String(error);
|
|
201
|
+
checks.push({ name, passed: false, error: message });
|
|
202
|
+
failures.push({ name, error: message });
|
|
203
|
+
console.log(`FAIL ${name}\n ${message}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function visibleMatches(locator) {
|
|
208
|
+
return locator.evaluateAll(elements => elements.map(element => {
|
|
209
|
+
const rect = element.getBoundingClientRect();
|
|
210
|
+
const style = getComputedStyle(element);
|
|
211
|
+
return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0
|
|
212
|
+
&& element.getClientRects().length > 0;
|
|
213
|
+
}).filter(Boolean).length);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function expectVisible(locator, label, minimum = 1) {
|
|
217
|
+
const visible = await visibleMatches(locator);
|
|
218
|
+
assert.ok(visible >= minimum, `${label}: expected >=${minimum} visible, got ${visible} (matches ${await locator.count()})`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function expectHidden(locator, label) {
|
|
222
|
+
const visible = await visibleMatches(locator);
|
|
223
|
+
assert.equal(visible, 0, `${label}: expected 0 visible, got ${visible} (matches ${await locator.count()})`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function expectCount(locator, expected, label) {
|
|
227
|
+
assert.equal(await locator.count(), expected, `${label}: expected ${expected} matches, got ${await locator.count()}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function top(locator, label) {
|
|
231
|
+
const element = locator.first();
|
|
232
|
+
await element.waitFor({ state: 'visible', timeout: 5000 }).catch(() => {});
|
|
233
|
+
return element.evaluate(node => Math.round(node.getBoundingClientRect().top + window.scrollY));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function expectOrder(locators) {
|
|
237
|
+
let previous;
|
|
238
|
+
const seen = [];
|
|
239
|
+
for (const { locator, label } of locators) {
|
|
240
|
+
const value = await top(locator, label);
|
|
241
|
+
seen.push(`${label}@${value}`);
|
|
242
|
+
if (previous !== undefined && !(value > previous.value)) {
|
|
243
|
+
throw new Error(`source order violated: ${label}@${value} is not after ${previous.label}@${previous.value} (${seen.join(' < ')})`);
|
|
244
|
+
}
|
|
245
|
+
previous = { value, label };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function domSnapshot() {
|
|
250
|
+
return page.evaluate(() => {
|
|
251
|
+
const section = element => {
|
|
252
|
+
if (!element) return null;
|
|
253
|
+
return {
|
|
254
|
+
turn: element.getAttribute('data-reader-turn'),
|
|
255
|
+
state: element.getAttribute('data-reader-turn-state'),
|
|
256
|
+
result: element.getAttribute('data-reader-turn-result'),
|
|
257
|
+
text: (element.innerText ?? '').replace(/\n+/gu, ' | ').slice(0, 700),
|
|
258
|
+
bodies: [...element.querySelectorAll('[data-reader-body]')].map(node => (node.textContent ?? '').trim().slice(0, 80)),
|
|
259
|
+
seams: element.querySelectorAll('[data-reader-process-seam]').length,
|
|
260
|
+
answers: [...element.querySelectorAll('[data-reader-answer]')].map(node => (node.textContent ?? '').trim().slice(0, 80)),
|
|
261
|
+
process: [...element.querySelectorAll('[data-reader-process]')].map(node => (node.textContent ?? '').trim().slice(0, 80)),
|
|
262
|
+
tools: [...element.querySelectorAll('[data-reader-tool-call]')].map(node => node.getAttribute('data-reader-tool-call')),
|
|
263
|
+
closedSummary: element.querySelector('[data-reader-closed-summary]')?.getAttribute('data-reader-closed-summary') ?? null,
|
|
264
|
+
liveFolds: [...element.querySelectorAll('[data-reader-live-fold]')].map(node => node.getAttribute('data-reader-live-fold-summary')),
|
|
265
|
+
expandButtons: [...element.querySelectorAll('button[aria-label]')]
|
|
266
|
+
.map(node => node.getAttribute('aria-label'))
|
|
267
|
+
.filter(label => label && /^(展开|收起)/u.test(label)),
|
|
268
|
+
};
|
|
269
|
+
};
|
|
270
|
+
const rootNode = document.querySelector('[data-reader-build]');
|
|
271
|
+
return {
|
|
272
|
+
build: rootNode?.getAttribute('data-reader-build') ?? null,
|
|
273
|
+
autoFold: rootNode?.getAttribute('data-reader-auto-fold') ?? null,
|
|
274
|
+
turns: [...document.querySelectorAll('[data-reader-turn]')].map(section),
|
|
275
|
+
};
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function screenshot(name, locator) {
|
|
280
|
+
const path = join(outDir, mode, `${name}.png`);
|
|
281
|
+
await mkdir(dirname(path), { recursive: true });
|
|
282
|
+
// Viewport screenshots keep the real chrome (sticky status lane, fold rows)
|
|
283
|
+
// in the evidence; scroll the turn of interest into view first.
|
|
284
|
+
if (locator) {
|
|
285
|
+
await locator.first().evaluate(element => element.scrollIntoView({ block: 'center', behavior: 'instant' })).catch(() => {});
|
|
286
|
+
await page.waitForTimeout(120);
|
|
287
|
+
}
|
|
288
|
+
await page.screenshot({ path });
|
|
289
|
+
return path;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function git(args_) {
|
|
293
|
+
try { return execFileSync('git', ['-C', root, ...args_], { encoding: 'utf8' }).trim(); } catch { return null; }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const sourceFiles = ['src/client/Reader.tsx', 'src/client/live-turn.ts', 'src/client/projection.ts'];
|
|
297
|
+
async function sourceHashes() {
|
|
298
|
+
const hashes = {};
|
|
299
|
+
for (const file of sourceFiles) {
|
|
300
|
+
try { hashes[file] = createHash('md5').update(await readFile(join(root, file))).digest('hex'); }
|
|
301
|
+
catch { hashes[file] = null; }
|
|
302
|
+
}
|
|
303
|
+
return hashes;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// --- run -------------------------------------------------------------------
|
|
307
|
+
const browser = await launchChromium();
|
|
308
|
+
const shots = [];
|
|
309
|
+
const result = {
|
|
310
|
+
mode, root, url, startedAt: new Date().toISOString(),
|
|
311
|
+
git: { commit: git(['rev-parse', 'HEAD']), branch: git(['rev-parse', '--abbrev-ref', 'HEAD']), tags: git(['tag', '--points-at', 'HEAD']), status: git(['status', '--porcelain']) },
|
|
312
|
+
sourceHashes: await sourceHashes(),
|
|
313
|
+
checks, failures, snapshots: {}, shots,
|
|
314
|
+
};
|
|
315
|
+
try {
|
|
316
|
+
page = await browser.newPage({ viewport: { width: 1280, height: 1100 }, reducedMotion });
|
|
317
|
+
page.setDefaultTimeout(6000);
|
|
318
|
+
page.on('pageerror', error => errors.push(String(error)));
|
|
319
|
+
page.on('console', message => { if (message.type() === 'error') errors.push(`console: ${message.text()}`); });
|
|
320
|
+
await page.goto(url);
|
|
321
|
+
await page.getByRole('button', { name: '自动折叠开', exact: true }).waitFor();
|
|
322
|
+
await page.waitForTimeout(250);
|
|
323
|
+
|
|
324
|
+
const turn1 = page.locator('[data-reader-turn="1"]');
|
|
325
|
+
const turn2 = page.locator('[data-reader-turn="2"]');
|
|
326
|
+
const turn3 = page.locator('[data-reader-turn="3"]');
|
|
327
|
+
const turn4 = page.locator('[data-reader-turn="4"]');
|
|
328
|
+
const turn5 = page.locator('[data-reader-turn="5"]');
|
|
329
|
+
const bodyA = () => turn1.getByText(texts.bodyA, { exact: true });
|
|
330
|
+
const reasonA = () => turn1.getByText(texts.reasonA, { exact: true });
|
|
331
|
+
const finalB = () => turn1.getByText(texts.finalB, { exact: true });
|
|
332
|
+
const reasonB = () => turn1.getByText(texts.reasonB, { exact: true });
|
|
333
|
+
const bodyC = () => turn2.getByText(texts.bodyC, { exact: true });
|
|
334
|
+
const reasonC0 = () => turn2.getByText(texts.reasonC0, { exact: true });
|
|
335
|
+
const reasonC1 = () => turn2.getByText(texts.reasonC1, { exact: true });
|
|
336
|
+
const toolCall = () => turn1.locator(`[data-reader-tool-call="${texts.toolCallId}"]`);
|
|
337
|
+
const toggleOff = async () => {
|
|
338
|
+
await page.getByRole('button', { name: '自动折叠开', exact: true }).click();
|
|
339
|
+
await page.getByRole('button', { name: '自动折叠关', exact: true }).waitFor();
|
|
340
|
+
};
|
|
341
|
+
const toggleOn = async () => {
|
|
342
|
+
await page.getByRole('button', { name: '自动折叠关', exact: true }).click();
|
|
343
|
+
await page.getByRole('button', { name: '自动折叠开', exact: true }).waitFor();
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
if (mode === 'red') {
|
|
347
|
+
// Record the baseline under test; the red/green difference is behavioral.
|
|
348
|
+
await check('red baseline identified (auto-fold on)', async () => {
|
|
349
|
+
const snapshot = await domSnapshot();
|
|
350
|
+
result.snapshots.initial = snapshot;
|
|
351
|
+
assert.ok(snapshot.build, 'data-reader-build marker missing');
|
|
352
|
+
assert.equal(snapshot.autoFold, 'on');
|
|
353
|
+
console.log(` baseline build marker: ${snapshot.build}`);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
await check('fixture sanity: completed turn final answer is visible', async () => {
|
|
357
|
+
await expectVisible(finalB(), '最终回答B');
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
await check('RED completed turn: 正文A is folded away before expanding', async () => {
|
|
361
|
+
await expectHidden(bodyA(), '过程汇报正文A');
|
|
362
|
+
assert.equal(await bodyA().count(), 0, `正文A should not be mounted at all (count ${await bodyA().count()})`);
|
|
363
|
+
shots.push(await screenshot('red-completed-collapsed', turn1));
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
await check('RED completed turn: 正文A appears INSIDE the process disclosure after expanding', async () => {
|
|
367
|
+
await turn1.getByRole('button', { name: /^展开(思考与过程|本轮过程详情)$/u }).first().click();
|
|
368
|
+
await expectVisible(bodyA(), '过程汇报正文A after expand');
|
|
369
|
+
const inProcess = await turn1.locator('[data-reader-process]').getByText(texts.bodyA, { exact: true }).count();
|
|
370
|
+
assert.ok(inProcess >= 1, `正文A was expected to be rendered inside [data-reader-process], count=${inProcess}`);
|
|
371
|
+
result.snapshots.redExpanded = await domSnapshot();
|
|
372
|
+
shots.push(await screenshot('red-completed-expanded-body-in-process', turn1));
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
await check('RED open turn: 正文C is folded into the live fold before expanding', async () => {
|
|
376
|
+
await expectHidden(bodyC(), '进行中汇报正文C');
|
|
377
|
+
const folds = turn2.locator('[data-reader-live-fold]');
|
|
378
|
+
const foldCount = await folds.count();
|
|
379
|
+
assert.ok(foldCount >= 1, `expected a live fold in the open turn, got ${foldCount}`);
|
|
380
|
+
const summary = await folds.first().getAttribute('data-reader-live-fold-summary');
|
|
381
|
+
result.snapshots.redLiveFoldSummary = summary;
|
|
382
|
+
assert.match(summary ?? '', /输出×1/u, `live fold summary should count the narration as folded body: ${summary}`);
|
|
383
|
+
shots.push(await screenshot('red-open-live-folded', turn2));
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
await check('RED open turn: expanding the live fold reveals 正文C (as a folded member)', async () => {
|
|
387
|
+
await turn2.getByRole('button', { name: '展开此前步骤', exact: true }).click();
|
|
388
|
+
await expectVisible(bodyC(), '进行中汇报正文C after expanding prior steps');
|
|
389
|
+
result.snapshots.redOpenExpanded = await domSnapshot();
|
|
390
|
+
shots.push(await screenshot('red-open-fold-expanded', turn2));
|
|
391
|
+
});
|
|
392
|
+
} else {
|
|
393
|
+
// ===================== green contract =====================
|
|
394
|
+
await check('fixed build identified (auto-fold on)', async () => {
|
|
395
|
+
const snapshot = await domSnapshot();
|
|
396
|
+
result.snapshots.initial = snapshot;
|
|
397
|
+
assert.ok(snapshot.build, 'data-reader-build marker missing');
|
|
398
|
+
assert.equal(snapshot.autoFold, 'on');
|
|
399
|
+
console.log(` fixed build marker: ${snapshot.build}`);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
await check('a1 completed/collapsed: 正文A visible without expanding, unique, and NOT inside a fold', async () => {
|
|
403
|
+
await expectVisible(bodyA(), '过程汇报正文A');
|
|
404
|
+
await expectCount(bodyA(), 1, '过程汇报正文A');
|
|
405
|
+
await expectVisible(finalB(), '最终回答B');
|
|
406
|
+
const closed = turn1.locator('[data-reader-closed-summary]');
|
|
407
|
+
await expectCount(closed, 1, 'ClosedProcessSummary');
|
|
408
|
+
await expectVisible(closed, 'ClosedProcessSummary');
|
|
409
|
+
const summary = await closed.getAttribute('data-reader-closed-summary');
|
|
410
|
+
assert.ok(!/正文/u.test(summary ?? ''), `ClosedProcessSummary must not hold body: ${summary}`);
|
|
411
|
+
assert.equal(await closed.getByText(texts.bodyA, { exact: true }).count(), 0, '正文A leaked into ClosedProcessSummary');
|
|
412
|
+
assert.equal(await turn1.locator('[data-reader-live-fold]').getByText(texts.bodyA, { exact: true }).count(), 0, '正文A leaked into live fold');
|
|
413
|
+
assert.equal(await turn1.locator('[data-reader-process]').getByText(texts.bodyA, { exact: true }).count(), 0, '正文A leaked into a process disclosure');
|
|
414
|
+
result.snapshots.completedCollapsed = await domSnapshot();
|
|
415
|
+
shots.push(await screenshot('green-completed-collapsed', turn1));
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
await check('a2 completed/collapsed: reasoning and tool rows are hidden by default', async () => {
|
|
419
|
+
await expectHidden(reasonA(), '历史思考');
|
|
420
|
+
await expectHidden(reasonB(), '第二次思考');
|
|
421
|
+
await expectHidden(toolCall(), 'tool row');
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
await check('a3 top disclosure expands: reasoning/tool visible, in source order between the body articles', async () => {
|
|
425
|
+
const disclosure = turn1.getByRole('button', { name: /^展开(思考与过程|本轮过程详情)$/u });
|
|
426
|
+
assert.ok(await disclosure.count() >= 1, 'no 展开思考与过程/展开本轮过程详情 button found');
|
|
427
|
+
await disclosure.first().click();
|
|
428
|
+
await expectVisible(reasonA(), '历史思考 after expand');
|
|
429
|
+
await expectVisible(reasonB(), '第二次思考 after expand');
|
|
430
|
+
await expectVisible(toolCall(), 'tool row after expand');
|
|
431
|
+
await expectVisible(bodyA(), '过程汇报正文A after expand');
|
|
432
|
+
await expectCount(bodyA(), 1, '过程汇报正文A');
|
|
433
|
+
await expectVisible(finalB(), '最终回答B after expand');
|
|
434
|
+
assert.equal(await turn1.locator('[data-reader-process]').getByText(texts.bodyA, { exact: true }).count(), 0, '正文A leaked into a process disclosure after expand');
|
|
435
|
+
await expectOrder([
|
|
436
|
+
{ locator: reasonA(), label: '历史思考' },
|
|
437
|
+
{ locator: bodyA(), label: '过程汇报正文A' },
|
|
438
|
+
{ locator: toolCall(), label: 'tool row' },
|
|
439
|
+
{ locator: reasonB(), label: '第二次思考' },
|
|
440
|
+
{ locator: finalB(), label: '最终回答B' },
|
|
441
|
+
]);
|
|
442
|
+
result.snapshots.completedExpanded = await domSnapshot();
|
|
443
|
+
shots.push(await screenshot('green-completed-expanded', turn1));
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
await check('a4 collapsing again removes reasoning/tool but keeps 正文A', async () => {
|
|
447
|
+
const disclosure = turn1.getByRole('button', { name: /^收起(思考与过程|本轮过程详情)$/u });
|
|
448
|
+
assert.ok(await disclosure.count() >= 1, 'no 收起思考与过程/收起本轮过程详情 button found');
|
|
449
|
+
await disclosure.first().click();
|
|
450
|
+
await expectHidden(reasonA(), '历史思考 after collapse');
|
|
451
|
+
await expectHidden(toolCall(), 'tool row after collapse');
|
|
452
|
+
await expectVisible(bodyA(), '过程汇报正文A after collapse');
|
|
453
|
+
await expectCount(bodyA(), 1, '过程汇报正文A');
|
|
454
|
+
await expectVisible(finalB(), '最终回答B after collapse');
|
|
455
|
+
result.snapshots.completedCollapsedAgain = await domSnapshot();
|
|
456
|
+
shots.push(await screenshot('green-completed-collapsed-again', turn1));
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
await check('b1 open turn: 正文C visible while the earlier reasoning folds across it', async () => {
|
|
460
|
+
await expectVisible(bodyC(), '进行中汇报正文C');
|
|
461
|
+
await expectCount(bodyC(), 1, '进行中汇报正文C');
|
|
462
|
+
await expectVisible(reasonC1(), '第二轮思考');
|
|
463
|
+
assert.equal(await turn2.locator('[data-reader-process]').getByText(texts.bodyC, { exact: true }).count(), 0, '正文C leaked into a process disclosure');
|
|
464
|
+
const folds = turn2.locator('[data-reader-live-fold]');
|
|
465
|
+
const foldCount = await folds.count();
|
|
466
|
+
result.snapshots.openTurnFoldCount = foldCount;
|
|
467
|
+
// The earlier reasoning run must fold even though narration sits between
|
|
468
|
+
// it and the latest reasoning; otherwise a live turn expands every past
|
|
469
|
+
// thinking card (the reported regression).
|
|
470
|
+
assert.equal(foldCount, 1, `live turn should fold the prior reasoning run across narration, got ${foldCount}`);
|
|
471
|
+
await expectHidden(reasonC0(), '第一轮思考 while collapsed');
|
|
472
|
+
const liveSummary = await folds.first().getAttribute('data-reader-live-fold-summary');
|
|
473
|
+
assert.equal(liveSummary, '思考×1', `live fold summary: ${liveSummary}`);
|
|
474
|
+
if (foldCount > 0) {
|
|
475
|
+
const summary = await folds.first().getAttribute('data-reader-live-fold-summary');
|
|
476
|
+
assert.ok(!/输出/u.test(summary ?? '') && !/正文/u.test(summary ?? ''), `live fold must not fold body: ${summary}`);
|
|
477
|
+
assert.equal(await folds.getByText(texts.bodyC, { exact: true }).count(), 0, '正文C leaked into a live fold');
|
|
478
|
+
await turn2.getByRole('button', { name: '展开此前步骤', exact: true }).click();
|
|
479
|
+
await expectVisible(reasonC0(), '第一轮思考 after expanding prior steps');
|
|
480
|
+
await expectVisible(bodyC(), '进行中汇报正文C after expanding prior steps');
|
|
481
|
+
await expectCount(bodyC(), 1, '进行中汇报正文C');
|
|
482
|
+
await expectOrder([
|
|
483
|
+
{ locator: reasonC0(), label: '第一轮思考' },
|
|
484
|
+
{ locator: bodyC(), label: '进行中汇报正文C' },
|
|
485
|
+
{ locator: reasonC1(), label: '第二轮思考' },
|
|
486
|
+
]);
|
|
487
|
+
shots.push(await screenshot('green-open-prior-steps-expanded'));
|
|
488
|
+
await turn2.getByRole('button', { name: '收起此前步骤', exact: true }).click();
|
|
489
|
+
await expectHidden(reasonC0(), '第一轮思考 after collapsing prior steps');
|
|
490
|
+
await expectVisible(bodyC(), '进行中汇报正文C after collapsing prior steps');
|
|
491
|
+
} else {
|
|
492
|
+
await expectVisible(reasonC0(), '第一轮思考');
|
|
493
|
+
await expectOrder([
|
|
494
|
+
{ locator: reasonC0(), label: '第一轮思考' },
|
|
495
|
+
{ locator: bodyC(), label: '进行中汇报正文C' },
|
|
496
|
+
{ locator: reasonC1(), label: '第二轮思考' },
|
|
497
|
+
]);
|
|
498
|
+
}
|
|
499
|
+
result.snapshots.openTurn = await domSnapshot();
|
|
500
|
+
shots.push(await screenshot('green-open-turn', turn2));
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
await check('c1 auto-fold off: completed turn shows body and all process content', async () => {
|
|
504
|
+
await toggleOff();
|
|
505
|
+
await expectVisible(bodyA(), '过程汇报正文A with auto-fold off');
|
|
506
|
+
await expectCount(bodyA(), 1, '过程汇报正文A');
|
|
507
|
+
await expectVisible(reasonA(), '历史思考 with auto-fold off');
|
|
508
|
+
await expectVisible(reasonB(), '第二次思考 with auto-fold off');
|
|
509
|
+
await expectVisible(toolCall(), 'tool row with auto-fold off');
|
|
510
|
+
await expectVisible(finalB(), '最终回答B with auto-fold off');
|
|
511
|
+
await expectVisible(bodyC(), '进行中汇报正文C with auto-fold off');
|
|
512
|
+
await expectVisible(reasonC0(), '第一轮思考 with auto-fold off');
|
|
513
|
+
result.snapshots.autoFoldOff = await domSnapshot();
|
|
514
|
+
shots.push(await screenshot('green-auto-fold-off', turn1));
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
await check('c2 auto-fold restored: completed turn folds reasoning again, body stays', async () => {
|
|
518
|
+
await toggleOn();
|
|
519
|
+
await page.waitForTimeout(120);
|
|
520
|
+
await expectVisible(bodyA(), '过程汇报正文A after restoring auto-fold');
|
|
521
|
+
await expectHidden(reasonA(), '历史思考 after restoring auto-fold');
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
await check('d1 failed turn: body and reasoning both visible without expanding', async () => {
|
|
525
|
+
await expectVisible(turn3.getByText(texts.bodyD, { exact: true }), '失败轮正文D');
|
|
526
|
+
await expectCount(turn3.getByText(texts.bodyD, { exact: true }), 1, '失败轮正文D');
|
|
527
|
+
await expectVisible(turn3.getByText(texts.reasonD, { exact: true }), '报错前的思考');
|
|
528
|
+
await expectVisible(turn3.getByText('本轮未完成;错误信息保留在下方。', { exact: true }), 'terminal notice');
|
|
529
|
+
shots.push(await screenshot('green-failed-turn', turn3));
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
await check('d2 interrupted turn: body and reasoning both visible without expanding', async () => {
|
|
533
|
+
await expectVisible(turn4.getByText(texts.bodyE, { exact: true }), '中断轮正文E');
|
|
534
|
+
await expectCount(turn4.getByText(texts.bodyE, { exact: true }), 1, '中断轮正文E');
|
|
535
|
+
await expectVisible(turn4.getByText(texts.reasonE, { exact: true }), '中断前的思考');
|
|
536
|
+
shots.push(await screenshot('green-interrupted-turn', turn4));
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
await check('e1 body stays plain prose; the process seam sits between collapsed report paragraphs', async () => {
|
|
540
|
+
const bodyF = () => turn5.getByText(texts.bodyF, { exact: false });
|
|
541
|
+
const bodyG = () => turn5.getByText(texts.bodyG, { exact: false });
|
|
542
|
+
await expectVisible(bodyF(), '第一段汇报');
|
|
543
|
+
await expectVisible(bodyG(), '补充汇报');
|
|
544
|
+
await expectVisible(turn5.getByText(texts.finalH, { exact: true }), '最终回答H');
|
|
545
|
+
await expectHidden(turn5.getByText(texts.reasonF0, { exact: true }), '汇报前的思考');
|
|
546
|
+
await expectHidden(turn5.getByText(texts.reasonG, { exact: true }), '补充说明前的思考');
|
|
547
|
+
|
|
548
|
+
// All narration blocks are ordinary body prose: no annotation styling and
|
|
549
|
+
// no left rule of their own.
|
|
550
|
+
const bodies = turn5.locator('[data-reader-body]');
|
|
551
|
+
assert.equal(await bodies.count(), 3, `expected three body blocks in turn 5, got ${await bodies.count()}`);
|
|
552
|
+
const bodyBoxes = await bodies.evaluateAll(elements => elements.map(element => {
|
|
553
|
+
const rect = element.getBoundingClientRect();
|
|
554
|
+
const style = getComputedStyle(element);
|
|
555
|
+
return {
|
|
556
|
+
top: rect.top + window.scrollY, bottom: rect.bottom + window.scrollY, left: rect.left + window.scrollX,
|
|
557
|
+
borderLeftWidth: style.borderLeftWidth, fontSize: style.fontSize, color: style.color,
|
|
558
|
+
};
|
|
559
|
+
}));
|
|
560
|
+
for (const box of bodyBoxes) {
|
|
561
|
+
assert.equal(box.borderLeftWidth, '0px', `narration body must not carry an annotation rule: ${JSON.stringify(box)}`);
|
|
562
|
+
assert.equal(box.fontSize, '16px', `narration body must use normal body typography: ${JSON.stringify(box)}`);
|
|
563
|
+
}
|
|
564
|
+
assert.ok(new Set(bodyBoxes.map(box => box.color)).size === 1,
|
|
565
|
+
`narration and answer must share normal body color: ${bodyBoxes.map(box => box.color)}`);
|
|
566
|
+
|
|
567
|
+
// A process-chain summary occupies the gap between collapsed report
|
|
568
|
+
// paragraphs: left rail + 思考×N · 工具×K + chevron, one per gap.
|
|
569
|
+
const seams = turn5.locator('[data-reader-process-seam]');
|
|
570
|
+
assert.equal(await seams.count(), 2, `expected two process seams, got ${await seams.count()}`);
|
|
571
|
+
const seamBoxes = await seams.evaluateAll(elements => elements.map(element => {
|
|
572
|
+
const rect = element.getBoundingClientRect();
|
|
573
|
+
const style = getComputedStyle(element);
|
|
574
|
+
const button = element.querySelector('button');
|
|
575
|
+
return {
|
|
576
|
+
top: rect.top + window.scrollY, bottom: rect.bottom + window.scrollY, height: rect.height,
|
|
577
|
+
borderLeftWidth: style.borderLeftWidth, borderLeftColor: style.borderLeftColor,
|
|
578
|
+
summary: element.getAttribute('data-reader-process-seam-summary'),
|
|
579
|
+
label: button?.getAttribute('aria-label'),
|
|
580
|
+
foldSummary: element.querySelector('[data-reader-fold-summary]')?.getAttribute('data-reader-fold-summary'),
|
|
581
|
+
};
|
|
582
|
+
}));
|
|
583
|
+
for (let index = 0; index < seamBoxes.length; index++) {
|
|
584
|
+
const seam = seamBoxes[index];
|
|
585
|
+
const before = bodyBoxes[index];
|
|
586
|
+
const after = bodyBoxes[index + 1];
|
|
587
|
+
assert.ok(seam.height >= 32, `process seam needs real vertical extent: ${JSON.stringify(seam)}`);
|
|
588
|
+
assert.ok(seam.top >= before.bottom - 0.5, `seam must start after the previous body: ${JSON.stringify({ before, seam })}`);
|
|
589
|
+
assert.ok(seam.bottom <= after.top + 0.5, `seam must end before the next body: ${JSON.stringify({ seam, after })}`);
|
|
590
|
+
assert.ok(after.top - before.bottom >= 32, `seam must own the paragraph gap: ${JSON.stringify({ before, after })}`);
|
|
591
|
+
assert.equal(seam.borderLeftWidth, '1px', `process seam rail width: ${JSON.stringify(seam)}`);
|
|
592
|
+
assert.ok(seam.borderLeftColor !== 'rgba(0, 0, 0, 0)', `process seam rail is transparent: ${JSON.stringify(seam)}`);
|
|
593
|
+
assert.match(seam.summary ?? '', /思考×|工具×|记录×/u, `seam summary must use process-chain counts: ${JSON.stringify(seam)}`);
|
|
594
|
+
assert.equal(seam.label, '展开被折叠的过程', `seam must expose an expand control: ${JSON.stringify(seam)}`);
|
|
595
|
+
assert.equal(seam.foldSummary, seam.summary, `seam must render the process-chain summary text: ${JSON.stringify(seam)}`);
|
|
596
|
+
}
|
|
597
|
+
result.snapshots.bodySeams = { bodyBoxes, seamBoxes };
|
|
598
|
+
shots.push(await screenshot('green-body-process-seam', turn5));
|
|
599
|
+
|
|
600
|
+
// The seam expands the real process rows in place instead of being decoration.
|
|
601
|
+
await seams.first().getByRole('button', { name: '展开被折叠的过程', exact: true }).click();
|
|
602
|
+
await expectVisible(turn5.getByText(texts.reasonF0, { exact: true }), '汇报前的思考 after expanding the seam');
|
|
603
|
+
await expectVisible(turn5.getByText(texts.reasonG, { exact: true }), '补充说明前的思考 after expanding the seam');
|
|
604
|
+
assert.equal(await turn5.locator('[data-reader-process-seam]').count(), 0, 'seams must disappear while the process is expanded');
|
|
605
|
+
await turn5.getByRole('button', { name: /^收起(思考与过程|本轮过程详情)$/u }).first().click();
|
|
606
|
+
assert.equal(await turn5.locator('[data-reader-process-seam]').count(), 2, 'seams must return when the process folds again');
|
|
607
|
+
await expectHidden(turn5.getByText(texts.reasonF0, { exact: true }), '汇报前的思考 after folding again');
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
await check('e no page errors and no unrenderable fallback', async () => {
|
|
611
|
+
assert.deepEqual(errors, []);
|
|
612
|
+
assert.equal(await page.getByText('此内容暂时无法展示', { exact: false }).count(), 0);
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
} finally {
|
|
616
|
+
result.errors = errors;
|
|
617
|
+
result.finishedAt = new Date().toISOString();
|
|
618
|
+
result.snapshots.final = page ? await domSnapshot().catch(() => null) : null;
|
|
619
|
+
result.passed = failures.length === 0;
|
|
620
|
+
result.shots = shots;
|
|
621
|
+
await mkdir(join(outDir, mode), { recursive: true });
|
|
622
|
+
await writeFile(join(outDir, mode, 'result.json'), JSON.stringify(result, null, 2));
|
|
623
|
+
await browser.close();
|
|
624
|
+
await new Promise(done => server.close(done));
|
|
625
|
+
}
|
|
626
|
+
console.log(JSON.stringify({ mode, passed: result.passed, git: result.git, checks: checks.map(item => ({ name: item.name, passed: item.passed, error: item.error })), shots }, null, 2));
|
|
627
|
+
process.exitCode = failures.length ? 1 : 0;
|