返回 DeepSeek-Reasonix
app-browser.mjs
根目录 / desktop / frontend / bench / app-browser.mjs
1 #!/usr/bin/env node
2
3 import path from "node:path";
4 import { fileURLToPath } from "node:url";
5 import { startPreviewServer } from "./vite-preview-server.mjs";
6 import { newSessionButton, selectSession } from "./app-page-actions.mjs";
7
8 const frontendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
9 process.env.PLAYWRIGHT_BROWSERS_PATH = !process.env.PLAYWRIGHT_BROWSERS_PATH || process.env.PLAYWRIGHT_BROWSERS_PATH === ".pw-browsers"
10 ? path.join(frontendDir, ".pw-browsers")
11 : process.env.PLAYWRIGHT_BROWSERS_PATH;
12 // Playwright reads PLAYWRIGHT_BROWSERS_PATH at module evaluation; import it
13 // only after the path normalization above.
14 const { chromium } = await import("playwright");
15 const port = Number(process.env.REASONIX_APP_BROWSER_PORT ?? 4657);
16 const preview = await startPreviewServer(frontendDir, port);
17 const browser = await chromium.launch({ headless: true });
18
19 function assert(condition, message) {
20 if (!condition) throw new Error(message);
21 process.stdout.write(` PASS ${message}\n`);
22 }
23
24 async function settle(page, frames = 5) {
25 await page.evaluate((count) => new Promise((resolve) => {
26 const tick = () => --count <= 0 ? resolve() : requestAnimationFrame(tick);
27 requestAnimationFrame(tick);
28 }), frames);
29 }
30
31 try {
32 const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
33 const pageErrors = [];
34 page.on("pageerror", (error) => pageErrors.push(error.message));
35 await page.goto(`http://127.0.0.1:${port}/?mock=bench&bench=1&app-lifecycle-probe=1`, { waitUntil: "domcontentloaded" });
36 await page.locator("textarea.composer__input:not([aria-hidden=true])").waitFor();
37 await page.locator(".project-tree").first().waitFor();
38 await page.evaluate(() => {
39 window.__appBrowserIdentity = {
40 composer: document.querySelector("textarea.composer__input:not([aria-hidden=true])"),
41 sidebar: document.querySelector(".sidebar"),
42 };
43 });
44 const composer = page.locator("textarea.composer__input:not([aria-hidden=true])");
45 await selectSession(page, "bench:small-6t");
46 await page.waitForFunction(() => document.querySelector('.transcript')?.textContent?.includes('ASYNC LAYOUT EXPANSION COMPLETE'));
47 await composer.fill("layout-owned draft");
48 // Raw Markdown fallbacks become parsed DOM asynchronously. History preservation
49 // means stable node identity, not identical transient textContent.
50 const transcriptIdentity = () => {
51 const nodes = [...document.querySelectorAll('[data-chat-anchor-key]')];
52 window.__modelTranscriptNodes ??= nodes;
53 return nodes.map((node, index) => ({ key: node.dataset.chatAnchorKey, kind: node.dataset.chatKind,
54 sameHost: node === window.__modelTranscriptNodes[index] }));
55 };
56 const transcriptBeforeModel = await page.evaluate(transcriptIdentity);
57 assert(transcriptBeforeModel.length > 0, 'model replay starts with hydrated transcript blocks');
58 await page.locator('.modelsw__trigger:not(.effortsw__trigger)').click();
59 const nextModel = page.locator('.modelsw__item[role="option"]:not([aria-selected="true"])').first();
60 const nextModelName = await nextModel.locator('.modelsw__model').textContent();
61 await nextModel.click();
62 await page.waitForFunction(name => document.querySelector('.modelsw__label')?.textContent?.includes(name), nextModelName);
63 await page.waitForFunction(() => document.querySelector('textarea.composer__input:not([aria-hidden=true])')?.disabled === false
64 && window.__reasonixAppLifecycle?.snapshot().activeOperations === 0);
65 const transcriptAfterModel = await page.evaluate(transcriptIdentity);
66 const draftAfterModel = await composer.inputValue();
67 assert(draftAfterModel === 'layout-owned draft' && JSON.stringify(transcriptAfterModel) === JSON.stringify(transcriptBeforeModel),
68 'real model selection preserves source transcript, Composer draft and writable readiness');
69 // A fresh session now seeds Overview in an expanded empty dock. Add Files
70 // from the tab menu so the rest of the browser fixture can exercise the
71 // workspace tree and preview.
72 await page.getByRole('tab', { name: 'Overview', exact: true }).waitFor();
73 assert(await page.getByRole('tab', { name: 'Overview', exact: true }).count() === 1,
74 'fresh expanded workspace dock defaults to Overview');
75 if (await page.getByRole('tab', { name: 'Files', exact: true }).count() === 0) {
76 await page.locator('.workbench-dock__tab-add').click();
77 await page.locator('.tab-add-menu__item', { hasText: 'Files' }).first().click();
78 }
79 await page.getByRole('tab', { name: 'Files', exact: true }).click();
80 await page.locator('[data-workspace-path="README.md"]').click();
81 await page.waitForFunction(() => document.querySelector('.workspace-preview__body')?.textContent?.includes('Browser-dev workspace preview.'));
82 await page.evaluate(() => {
83 Object.assign(window.__appBrowserIdentity, {
84 workspace: document.querySelector('.workspace-panel'),
85 workspaceTree: document.querySelector('.workspace-tree'),
86 preview: document.querySelector('.workspace-preview__body'),
87 });
88 });
89
90 assert(await page.locator(".app.app--workbench").count() === 1, "workbench layout renders from the authoritative startup snapshot");
91
92 const identities = await page.evaluate(() => ({
93 composer: window.__appBrowserIdentity.composer === document.querySelector("textarea.composer__input:not([aria-hidden=true])"),
94 sidebar: window.__appBrowserIdentity.sidebar === document.querySelector(".sidebar"),
95 workspace: window.__appBrowserIdentity.workspace === document.querySelector('.workspace-panel'),
96 workspaceTree: window.__appBrowserIdentity.workspaceTree === document.querySelector('.workspace-tree'),
97 preview: window.__appBrowserIdentity.preview === document.querySelector('.workspace-preview__body'),
98 }));
99 assert(Object.values(identities).every(Boolean), "management-page visits retain Sidebar, Composer, actual WorkspacePanel/tree and file preview identity");
100
101 const terminalToggle = page.getByRole("button", { name: "Terminal", exact: true }).first();
102 await terminalToggle.click();
103 await page.locator('.terminal-drawer[aria-hidden="false"]').waitFor();
104 assert(await page.locator('.terminal-drawer-resizer[tabindex="0"]').count() === 1, "open terminal drawer exposes one keyboard resizer");
105 assert(await page.locator(".footer.footer--compact").count() === 1, "open terminal compacts the shared footer without remounting Composer");
106 assert(await composer.inputValue() === "layout-owned draft", "terminal drawer lifecycle preserves the Composer draft");
107 await terminalToggle.click();
108 const closedTerminal = page.locator('.terminal-drawer[aria-hidden="true"][inert]');
109 await closedTerminal.waitFor({ state: "attached" });
110 await closedTerminal.waitFor({ state: "hidden" });
111 assert(await closedTerminal.count() === 1, "closed warm terminal remains mounted and hidden");
112 assert(await page.locator('.terminal-drawer-resizer[tabindex="-1"]').count() === 1, "closed warm terminal is inert and leaves keyboard navigation");
113
114 await selectSession(page, "bench:geometry");
115 await page.waitForFunction(() => document.querySelector(".transcript")?.textContent?.includes("Geometry contract fixture complete."));
116 await selectSession(page, "bench:small-6t");
117 await page.waitForFunction(() => document.querySelector(".transcript")?.textContent?.includes("ASYNC LAYOUT EXPANSION COMPLETE"));
118 const afterSwitch = await page.evaluate(() => ({
119 workspace: window.__appBrowserIdentity.workspace === document.querySelector('.workspace-panel'),
120 workspaceTree: window.__appBrowserIdentity.workspaceTree === document.querySelector('.workspace-tree'),
121 preview: window.__appBrowserIdentity.preview === document.querySelector('.workspace-preview__body'),
122 selectedFile: document.querySelector('.workspace-tree__row--active')?.getAttribute('data-workspace-path'),
123 subscriptions: window.__reasonixAppLifecycle?.snapshot().activeSubscriptions,
124 operations: window.__reasonixAppLifecycle?.snapshot().activeOperations,
125 }));
126 assert(afterSwitch.workspace && afterSwitch.workspaceTree && afterSwitch.preview && afterSwitch.selectedFile === 'README.md',
127 'same-project session switching preserves actual WorkspacePanel, tree, preview DOM and selected file');
128 assert(afterSwitch.subscriptions === 6, `the six AppRuntimeEffects subscriptions remain singular (${afterSwitch.subscriptions})`);
129 assert(afterSwitch.operations === 0, "instrumented operation owners report zero active operations (not yet all App operations)");
130
131 // Browser-mock local switches exercise the renderer latency contract. Use
132 // the navigation surface's paint receipt (the same click-to-first-paint
133 // milestone reported in diagnostics), not completion of deferred Markdown
134 // or lazy-content expansion after the first screen is already visible.
135 const switchSamples = [];
136 for (let index = 0; index < 20; index += 1) {
137 const geometry = index % 2 === 0;
138 const label = geometry ? "bench:geometry" : "bench:small-6t";
139 // The latency gate stops at the first readable inline body. The full
140 // ASYNC marker intentionally lives beyond the lazy-content preview and
141 // is validated above; including its simulated 1.5s body fetch here would
142 // benchmark deferred expansion rather than first readable paint.
143 const marker = geometry ? "Geometry contract fixture complete." : "Asynchronously hydrated verification appendix";
144 const previousIntent = await page.evaluate(() => window.__reasonixPerf?.stats().navigation?.intent ?? -1);
145 await selectSession(page, label);
146 await page.waitForFunction((text) => document.querySelector(".transcript")?.textContent?.includes(text), marker);
147 await page.waitForFunction((intent) => {
148 const navigation = window.__reasonixPerf?.stats().navigation;
149 return navigation?.intent !== intent && navigation?.clickToFirstPaintMs !== undefined;
150 }, previousIntent);
151 switchSamples.push(await page.evaluate(() => window.__reasonixPerf.stats().navigation.clickToFirstPaintMs));
152 }
153 switchSamples.sort((a, b) => a - b);
154 const localSwitchP95 = switchSamples[Math.ceil(switchSamples.length * 0.95) - 1];
155 assert(localSwitchP95 <= 300,
156 `browser-mock local click-to-first-paint P95 <= 300ms (${localSwitchP95.toFixed(1)}ms; samples=${switchSamples.map(value => value.toFixed(1)).join(",")})`);
157
158 await page.locator('.project-tree__folder-main:has(svg.lucide-cloud)').click();
159 await page.locator('.project-tree__topic-main:has-text("Remote demo session")').click();
160 await page.locator('.remote-surface--ready').waitFor();
161 await page.waitForFunction(() => document.querySelector('textarea.composer__input:not([aria-hidden=true])')?.disabled === false);
162 assert((await page.locator('.topicbar').textContent()).includes('Remote demo session'), "remote project selection adopts its source workspace and authoritative hydrated surface");
163 await newSessionButton(page).click();
164 await page.waitForFunction(() => document.querySelector('.topicbar')?.textContent?.includes('New session'));
165 await page.locator('.remote-surface--ready').waitFor();
166 await page.waitForFunction(() => document.querySelector('textarea.composer__input:not([aria-hidden=true])')?.disabled === false);
167 assert(await page.locator('.remote-surface').count() === 1, "global New Session stays on the remote workspace instead of opening a local blank");
168 assert(await page.evaluate(() => window.__appBrowserIdentity.composer === document.querySelector('textarea.composer__input:not([aria-hidden=true])')), "local/remote navigation and remote New Session preserve the Composer DOM identity");
169 await selectSession(page, "bench:geometry");
170 await page.waitForFunction(() => document.querySelector('.transcript')?.textContent?.includes('Geometry contract fixture complete.'));
171 assert(await page.locator('.remote-surface').count() === 0, "subsequent local navigation owns the surface; remote events do not reclaim it");
172 const sentText = 'App source-bound submission fixture';
173 await composer.fill(sentText);
174 await composer.press('Enter');
175 await page.locator('[data-chat-kind="user"]').filter({ hasText: sentText }).waitFor();
176 await page.locator('.composer__btn--stop').click();
177 await page.locator('.composer__btn--stop').waitFor({ state: 'hidden' });
178 await page.waitForFunction(() => document.querySelector('textarea.composer__input:not([aria-hidden=true])')?.disabled === false);
179 assert(await page.evaluate(() => window.__appBrowserIdentity.composer === document.querySelector('textarea.composer__input:not([aria-hidden=true])')),
180 'ordinary source-bound send and native Stop preserve Composer identity and restore writable readiness');
181 assert(pageErrors.length === 0, `layout replay emits no page errors (${pageErrors.length})`);
182
183 process.stdout.write("app browser lifecycle gate passed\n");
184 } finally {
185 await browser.close();
186 await preview.close();
187 }
188
188 lines Plain Text