返回 DeepSeek-Reasonix
settings-layout.mjs
根目录 / desktop / frontend / bench / settings-layout.mjs
1 #!/usr/bin/env node
2 import assert from "node:assert/strict";
3 import path from "node:path";
4 import { fileURLToPath } from "node:url";
5 import { startPreviewServer } from "./vite-preview-server.mjs";
6 import { chooseAppLayout } from "./app-page-actions.mjs";
7
8 const root = 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(root, ".pw-browsers") : process.env.PLAYWRIGHT_BROWSERS_PATH;
11 const engines = await import("playwright");
12 const port = Number(process.env.REASONIX_SETTINGS_PORT ?? 4679);
13 const preview = await startPreviewServer(root, port);
14 const themes = ["graphite", "aurora", "slate", "carbon", "nocturne", "amber"];
15 const sizes = [1600, 1100, 900, 700, 400];
16 const assignmentRows = 5;
17 let cases = 0;
18
19 async function settle(page) {
20 await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
21 }
22
23 async function verifySaveBars(page, context) {
24 for (const [tab, fields] of [["Hooks", ".hooks-json-panel__textarea"], ["Network", ".settings-page--network input"]]) {
25 await page.getByRole("navigation", { name: "Settings", exact: true }).getByRole("button", { name: tab, exact: true }).click();
26 if (tab === "Network") await page.getByRole("button", { name: "custom", exact: true }).click();
27 await page.locator(fields).first().waitFor();
28 for (const zoom of [1, 1.5]) {
29 await page.evaluate(zoom => { document.documentElement.style.zoom = String(zoom); }, zoom);
30 for (const [width, height] of [[1100, 700], [900, 600], [400, 600]]) {
31 await page.setViewportSize({ width, height });
32 for (const fraction of [0, 0.5, 1]) {
33 await page.locator(".settings-center__content").evaluate((el, fraction) => {
34 el.scrollTop = (el.scrollHeight - el.clientHeight) * fraction;
35 }, fraction);
36 await settle(page);
37 const overlap = await page.evaluate(fields => {
38 const bar = document.querySelector(".settings-save-bar").getBoundingClientRect();
39 return [...document.querySelectorAll(fields)].some(el => {
40 const field = el.getBoundingClientRect();
41 return field.top < bar.bottom && field.bottom > bar.top;
42 });
43 }, fields);
44 assert.equal(overlap, false, `${context}/${tab}/${width}x${height}/${zoom}x/${fraction}: save bar never covers form fields`);
45 }
46 const cancel = page.locator(".settings-save-bar").getByRole("button", { name: "Cancel", exact: true });
47 await cancel.focus();
48 const reachable = await cancel.evaluate(el => {
49 const r = el.getBoundingClientRect();
50 return el.contains(document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2));
51 });
52 assert.ok(reachable, `${context}/${tab}/${width}x${height}/${zoom}x: save bar remains reachable by keyboard`);
53 }
54 }
55 await page.evaluate(() => { document.documentElement.style.zoom = "1"; });
56 await page.setViewportSize({ width: 1600, height: 1100 });
57 }
58 console.log(`PASS ${context} Hooks and Network save bars`);
59 }
60
61 async function verifyUsageStatsLabels(browser, platform, context) {
62 const page = await browser.newPage({ locale: "zh-CN", viewport: { width: 1600, height: 1100 } });
63 try {
64 await page.goto(`http://127.0.0.1:${port}/?mock=deepseek_upgrade&bench=1&platform=${platform}`, { waitUntil: "domcontentloaded" });
65 await page.locator("textarea.composer__input:not([aria-hidden=true])").waitFor();
66 await page.locator('button:has(svg.lucide-settings)').last().click();
67 await page.locator(".settings-center__navitem:has(svg.lucide-chart-no-axes-column)").click();
68 await page.locator(".usage-stats__group > button").first().waitFor();
69 for (const width of [1600, 1100]) {
70 await page.setViewportSize({ width, height: 1100 });
71 await settle(page);
72 const clipped = await page.locator(".usage-stats__group > button").evaluateAll(buttons =>
73 buttons.filter(button => button.scrollWidth > button.clientWidth).map(button => button.textContent));
74 assert.deepEqual(clipped, [], `${context}/${width}px: usage range and source labels are shown whole`);
75 }
76 console.log(`PASS ${context} usage statistics labels`);
77 } finally { await page.close(); }
78 }
79
80 async function verifyFillWidthOptions(browser, platform, context) {
81 const page = await browser.newPage({ locale: "en-US", viewport: { width: 1600, height: 1100 } });
82 try {
83 await page.goto(`http://127.0.0.1:${port}/?mock=deepseek_upgrade&bench=1&platform=${platform}`, { waitUntil: "domcontentloaded" });
84 await page.locator("textarea.composer__input:not([aria-hidden=true])").waitFor();
85 await page.locator('button:has(svg.lucide-settings)').last().click();
86 // UsageStatsPanel is lazy-loaded; its stylesheet must be on the page before the gallery is measured.
87 await page.locator(".settings-center__navitem:has(svg.lucide-chart-no-axes-column)").click();
88 await page.locator(".usage-stats__group > button").first().waitFor();
89 await page.locator(".settings-center__navitem:has(svg.lucide-palette)").first().click();
90 await page.locator(".appearance-overview__hero-actions .btn--primary").click();
91 const groups = page.locator(".theme-gallery__preview-control .settings-options");
92 await groups.first().waitFor();
93 for (const width of [1600, 1100]) {
94 await page.setViewportSize({ width, height: 1100 });
95 await settle(page);
96 const spans = await groups.evaluateAll(gs => gs.map(group => ({
97 group: group.clientWidth,
98 buttons: [...group.querySelectorAll(":scope > button")].reduce((sum, b) => sum + b.getBoundingClientRect().width, 0),
99 })));
100 assert.ok(spans.length > 0, `${context}/${width}px: theme preview controls are present`);
101 for (const span of spans) {
102 assert.ok(Math.abs(span.buttons - span.group) <= 1, `${context}/${width}px: fill-width options span their group (${span.buttons} of ${span.group}px)`);
103 }
104 }
105 console.log(`PASS ${context} fill-width theme preview options`);
106 } finally { await page.close(); }
107 }
108
109 function geometry() {
110 const rect = el => {
111 const r = el.getBoundingClientRect();
112 return { left: r.left, right: r.right, top: r.top, bottom: r.bottom };
113 };
114 const group = document.querySelector(".model-preferences");
115 const head = group?.querySelector(".model-assignment-head");
116 const general = document.querySelector(".settings-page--general");
117 const nav = document.querySelector(".settings-center__nav");
118 const search = nav.querySelector(".settings-center__search");
119 const navgroups = nav.querySelector(".settings-center__navgroups");
120 return {
121 navigation: {
122 searchVisible: getComputedStyle(search).display !== "none",
123 search: rect(search),
124 list: rect(navgroups),
125 listOverflowY: getComputedStyle(navgroups).overflowY,
126 outerScroll: nav.scrollTop,
127 },
128 pageWidth: general?.clientWidth,
129 generalContainer: general && getComputedStyle(general).containerName,
130 soundColumns: general && [...general.querySelectorAll(".settings-sound-row")].map(row => getComputedStyle(row).gridTemplateColumns.split(" ").length),
131 statusColumns: general && getComputedStyle(general.querySelector(".status-bar-items-setting")).gridTemplateColumns.split(" ").length,
132 width: group?.clientWidth,
133 headVisible: head && getComputedStyle(head).display !== "none",
134 headers: head && [...head.children].map(rect),
135 rows: group && [...group.querySelectorAll(".model-assignment-row")].map(row => ({
136 bounds: rect(row),
137 label: rect(row.querySelector(".settings-field__copy")),
138 picker: rect(row.querySelector(".settings-model-picker")),
139 connection: rect(row.querySelector(".model-assignment-connection")),
140 columns: getComputedStyle(row).gridTemplateColumns.split(" ").length,
141 })),
142 };
143 }
144
145 try {
146 const targets = (process.env.REASONIX_SETTINGS_BROWSERS ?? "chromium").split(",")
147 .flatMap(engine => ["windows", "darwin", "linux"].map(platform => [engine, platform]));
148 for (const [engineName, platform] of targets) {
149 const browser = await engines[engineName].launch({ headless: true });
150 try {
151 await verifyUsageStatsLabels(browser, platform, `${engineName}/${platform}`);
152 await verifyFillWidthOptions(browser, platform, `${engineName}/${platform}`);
153 const page = await browser.newPage({ locale: "en-US", viewport: { width: 1600, height: 1100 } });
154 const errors = [];
155 page.on("pageerror", error => errors.push(error.message));
156 await page.goto(`http://127.0.0.1:${port}/?mock=deepseek_upgrade&bench=1&platform=${platform}`, { waitUntil: "domcontentloaded" });
157 await page.locator("textarea.composer__input:not([aria-hidden=true])").waitFor();
158 {
159 await page.setViewportSize({ width: 1600, height: 1100 });
160 await page.evaluate(() => { document.documentElement.style.zoom = "1"; });
161 await page.locator('button:has(svg.lucide-settings)').last().click();
162 await page.locator(".settings-page--general").waitFor();
163 await page.getByRole("button", { name: "Expand sound settings", exact: true }).click();
164 for (const width of sizes) {
165 await page.setViewportSize({ width, height: 1100 });
166 await settle(page);
167 const g = await page.evaluate(geometry);
168 assert.equal(g.generalContainer, "settings-general", "general page retains its responsive container");
169 if (g.pageWidth <= 440) {
170 assert.ok(g.soundColumns.length > 0, "sound controls are present");
171 assert.ok(g.soundColumns.every(columns => columns === 1), "narrow sound controls stack within the general page");
172 }
173 assert.equal(g.statusColumns, 1, `status bar editor owns one full-width column at ${width}px`);
174 }
175 console.log(`PASS ${engineName}/${platform} general settings`);
176 await page.setViewportSize({ width: 1600, height: 1100 });
177 await verifySaveBars(page, `${engineName}/${platform}`);
178 await page.getByRole("button", { name: "Model preferences", exact: true }).click();
179 await page.locator(".model-assignment-row").first().waitFor();
180 for (const theme of themes) {
181 for (const zoom of [1, 1.5]) {
182 await page.evaluate(({ theme, zoom }) => {
183 document.documentElement.dataset.themeStyle = theme;
184 document.documentElement.style.zoom = String(zoom);
185 }, { theme, zoom });
186 for (const width of sizes) {
187 await page.setViewportSize({ width, height: 1100 });
188 await settle(page);
189 const g = await page.evaluate(geometry);
190 const context = `${engineName}/${platform}/${theme}/${width}px/${zoom}x`;
191 if (g.navigation.searchVisible) {
192 assert.ok(g.navigation.list.top >= g.navigation.search.bottom, `${context}: navigation viewport stays below search`);
193 assert.equal(g.navigation.listOverflowY, "auto", `${context}: navigation list owns vertical scrolling`);
194 assert.equal(g.navigation.outerScroll, 0, `${context}: search container never scrolls with navigation`);
195 }
196 assert.equal(g.rows.length, assignmentRows, `${context}: all assignments remain visible`);
197 const columns = g.width > 780 ? 3 : g.width > 440 ? 2 : 1;
198 assert.equal(g.headVisible, columns === 3, `${context}: header follows content width`);
199 for (const row of g.rows) {
200 assert.equal(row.columns, columns, `${context}: assignment owns its columns`);
201 assert.ok(row.picker.left >= row.bounds.left - 1 && row.picker.right <= row.bounds.right + 1, `${context}: picker stays within row`);
202 assert.ok(row.connection.right <= row.bounds.right + 1, `${context}: connection stays within row`);
203 if (columns === 3) {
204 assert.ok(Math.abs(row.picker.left - g.headers[1].left) < 2, `${context}: model aligns with header`);
205 assert.ok(Math.abs(row.connection.left - g.headers[2].left) < 2, `${context}: connection aligns with header`);
206 assert.ok(row.connection.top < row.picker.bottom && row.connection.bottom > row.picker.top, `${context}: connection stays on same row`);
207 } else {
208 assert.ok(row.connection.top >= row.picker.bottom - 1, `${context}: connection follows its model`);
209 assert.ok(Math.abs(row.connection.left - row.picker.left) < 2, `${context}: stacked connection aligns with model`);
210 }
211 }
212 cases++;
213 }
214 }
215 }
216 await page.evaluate(() => { document.documentElement.style.zoom = "1"; });
217 await page.setViewportSize({ width: 1600, height: 1100 });
218 const search = page.getByRole("textbox", { name: "Search settings", exact: true });
219 const searchBefore = await search.boundingBox();
220 const navItems = page.locator(".settings-center__navitem");
221 // Derived, not a literal: the contract is "clearing search restores
222 // every item", so the page owns the total and a new settings page does
223 // not have to edit this benchmark.
224 const navItemCount = await navItems.count();
225 await navItems.last().scrollIntoViewIfNeeded();
226 await settle(page);
227 const scrolled = await page.evaluate(geometry);
228 assert.ok(scrolled.navigation.list.top >= scrolled.navigation.search.bottom, "scrolled navigation stays below search");
229 assert.equal(scrolled.navigation.outerScroll, 0, "revealing the last tab does not scroll search");
230 assert.deepEqual(await search.boundingBox(), searchBefore, "search stays fixed when revealing the last tab");
231 await search.fill("no-such-setting-regression");
232 await page.locator(".settings-center__navempty").waitFor();
233 await page.getByRole("button", { name: "Clear settings search", exact: true }).click();
234 assert.equal(await navItems.count(), navItemCount, "clearing search restores every navigation item");
235 const picker = page.getByRole("button", { name: "Default model", exact: true });
236 await picker.click();
237 await page.getByRole("listbox", { name: "Default model", exact: true }).waitFor();
238 await page.keyboard.press("Escape");
239 await page.getByRole("listbox", { name: "Default model", exact: true }).waitFor({ state: "detached" });
240 console.log(`PASS ${engineName}/${platform} navigation, assignments and picker`);
241 await page.locator(".settings-screen .management-screen__back").click();
242 }
243 assert.deepEqual(errors, [], `${engineName}/${platform}: no runtime errors`);
244 } finally { await browser.close(); }
245 }
246 console.log(`PASS settings layout: ${cases} real-page theme/layout/width/zoom cases plus general settings and picker interaction`);
247 } finally { await new Promise((resolve, reject) => preview.httpServer.close(error => error ? reject(error) : resolve())); }
248
248 lines Plain Text