| 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 | function geometry() { |
| 62 | const rect = el => { |
| 63 | const r = el.getBoundingClientRect(); |
| 64 | return { left: r.left, right: r.right, top: r.top, bottom: r.bottom }; |
| 65 | }; |
| 66 | const group = document.querySelector(".model-preferences"); |
| 67 | const head = group?.querySelector(".model-assignment-head"); |
| 68 | const general = document.querySelector(".settings-page--general"); |
| 69 | const nav = document.querySelector(".settings-center__nav"); |
| 70 | const search = nav.querySelector(".settings-center__search"); |
| 71 | const navgroups = nav.querySelector(".settings-center__navgroups"); |
| 72 | return { |
| 73 | navigation: { |
| 74 | searchVisible: getComputedStyle(search).display !== "none", |
| 75 | search: rect(search), |
| 76 | list: rect(navgroups), |
| 77 | listOverflowY: getComputedStyle(navgroups).overflowY, |
| 78 | outerScroll: nav.scrollTop, |
| 79 | }, |
| 80 | pageWidth: general?.clientWidth, |
| 81 | generalContainer: general && getComputedStyle(general).containerName, |
| 82 | soundColumns: general && [...general.querySelectorAll(".settings-sound-row")].map(row => getComputedStyle(row).gridTemplateColumns.split(" ").length), |
| 83 | statusColumns: general && getComputedStyle(general.querySelector(".status-bar-items-setting")).gridTemplateColumns.split(" ").length, |
| 84 | width: group?.clientWidth, |
| 85 | headVisible: head && getComputedStyle(head).display !== "none", |
| 86 | headers: head && [...head.children].map(rect), |
| 87 | rows: group && [...group.querySelectorAll(".model-assignment-row")].map(row => ({ |
| 88 | bounds: rect(row), |
| 89 | label: rect(row.querySelector(".settings-field__copy")), |
| 90 | picker: rect(row.querySelector(".settings-model-picker")), |
| 91 | connection: rect(row.querySelector(".model-assignment-connection")), |
| 92 | columns: getComputedStyle(row).gridTemplateColumns.split(" ").length, |
| 93 | })), |
| 94 | }; |
| 95 | } |
| 96 | |
| 97 | try { |
| 98 | const targets = (process.env.REASONIX_SETTINGS_BROWSERS ?? "chromium").split(",") |
| 99 | .flatMap(engine => ["windows", "darwin", "linux"].map(platform => [engine, platform])); |
| 100 | for (const [engineName, platform] of targets) { |
| 101 | const browser = await engines[engineName].launch({ headless: true }); |
| 102 | try { |
| 103 | const page = await browser.newPage({ locale: "en-US", viewport: { width: 1600, height: 1100 } }); |
| 104 | const errors = []; |
| 105 | page.on("pageerror", error => errors.push(error.message)); |
| 106 | await page.goto(`http://127.0.0.1:${port}/?mock=deepseek_upgrade&bench=1&platform=${platform}`, { waitUntil: "domcontentloaded" }); |
| 107 | await page.locator("textarea.composer__input:not([aria-hidden=true])").waitFor(); |
| 108 | { |
| 109 | await page.setViewportSize({ width: 1600, height: 1100 }); |
| 110 | await page.evaluate(() => { document.documentElement.style.zoom = "1"; }); |
| 111 | await page.locator('button:has(svg.lucide-settings)').last().click(); |
| 112 | await page.locator(".settings-page--general").waitFor(); |
| 113 | await page.getByRole("button", { name: "Expand sound settings", exact: true }).click(); |
| 114 | for (const width of sizes) { |
| 115 | await page.setViewportSize({ width, height: 1100 }); |
| 116 | await settle(page); |
| 117 | const g = await page.evaluate(geometry); |
| 118 | assert.equal(g.generalContainer, "settings-general", "general page retains its responsive container"); |
| 119 | if (g.pageWidth <= 440) { |
| 120 | assert.ok(g.soundColumns.length > 0, "sound controls are present"); |
| 121 | assert.ok(g.soundColumns.every(columns => columns === 1), "narrow sound controls stack within the general page"); |
| 122 | } |
| 123 | assert.equal(g.statusColumns, 1, `status bar editor owns one full-width column at ${width}px`); |
| 124 | } |
| 125 | console.log(`PASS ${engineName}/${platform} general settings`); |
| 126 | await page.setViewportSize({ width: 1600, height: 1100 }); |
| 127 | await verifySaveBars(page, `${engineName}/${platform}`); |
| 128 | await page.getByRole("button", { name: "Model preferences", exact: true }).click(); |
| 129 | await page.locator(".model-assignment-row").first().waitFor(); |
| 130 | for (const theme of themes) { |
| 131 | for (const zoom of [1, 1.5]) { |
| 132 | await page.evaluate(({ theme, zoom }) => { |
| 133 | document.documentElement.dataset.themeStyle = theme; |
| 134 | document.documentElement.style.zoom = String(zoom); |
| 135 | }, { theme, zoom }); |
| 136 | for (const width of sizes) { |
| 137 | await page.setViewportSize({ width, height: 1100 }); |
| 138 | await settle(page); |
| 139 | const g = await page.evaluate(geometry); |
| 140 | const context = `${engineName}/${platform}/${theme}/${width}px/${zoom}x`; |
| 141 | if (g.navigation.searchVisible) { |
| 142 | assert.ok(g.navigation.list.top >= g.navigation.search.bottom, `${context}: navigation viewport stays below search`); |
| 143 | assert.equal(g.navigation.listOverflowY, "auto", `${context}: navigation list owns vertical scrolling`); |
| 144 | assert.equal(g.navigation.outerScroll, 0, `${context}: search container never scrolls with navigation`); |
| 145 | } |
| 146 | assert.equal(g.rows.length, assignmentRows, `${context}: all assignments remain visible`); |
| 147 | const columns = g.width > 780 ? 3 : g.width > 440 ? 2 : 1; |
| 148 | assert.equal(g.headVisible, columns === 3, `${context}: header follows content width`); |
| 149 | for (const row of g.rows) { |
| 150 | assert.equal(row.columns, columns, `${context}: assignment owns its columns`); |
| 151 | assert.ok(row.picker.left >= row.bounds.left - 1 && row.picker.right <= row.bounds.right + 1, `${context}: picker stays within row`); |
| 152 | assert.ok(row.connection.right <= row.bounds.right + 1, `${context}: connection stays within row`); |
| 153 | if (columns === 3) { |
| 154 | assert.ok(Math.abs(row.picker.left - g.headers[1].left) < 2, `${context}: model aligns with header`); |
| 155 | assert.ok(Math.abs(row.connection.left - g.headers[2].left) < 2, `${context}: connection aligns with header`); |
| 156 | assert.ok(row.connection.top < row.picker.bottom && row.connection.bottom > row.picker.top, `${context}: connection stays on same row`); |
| 157 | } else { |
| 158 | assert.ok(row.connection.top >= row.picker.bottom - 1, `${context}: connection follows its model`); |
| 159 | assert.ok(Math.abs(row.connection.left - row.picker.left) < 2, `${context}: stacked connection aligns with model`); |
| 160 | } |
| 161 | } |
| 162 | cases++; |
| 163 | } |
| 164 | } |
| 165 | } |
| 166 | await page.evaluate(() => { document.documentElement.style.zoom = "1"; }); |
| 167 | await page.setViewportSize({ width: 1600, height: 1100 }); |
| 168 | const search = page.getByRole("textbox", { name: "Search settings", exact: true }); |
| 169 | const searchBefore = await search.boundingBox(); |
| 170 | const navItems = page.locator(".settings-center__navitem"); |
| 171 | // Derived, not a literal: the contract is "clearing search restores |
| 172 | // every item", so the page owns the total and a new settings page does |
| 173 | // not have to edit this benchmark. |
| 174 | const navItemCount = await navItems.count(); |
| 175 | await navItems.last().scrollIntoViewIfNeeded(); |
| 176 | await settle(page); |
| 177 | const scrolled = await page.evaluate(geometry); |
| 178 | assert.ok(scrolled.navigation.list.top >= scrolled.navigation.search.bottom, "scrolled navigation stays below search"); |
| 179 | assert.equal(scrolled.navigation.outerScroll, 0, "revealing the last tab does not scroll search"); |
| 180 | assert.deepEqual(await search.boundingBox(), searchBefore, "search stays fixed when revealing the last tab"); |
| 181 | await search.fill("no-such-setting-regression"); |
| 182 | await page.locator(".settings-center__navempty").waitFor(); |
| 183 | await page.getByRole("button", { name: "Clear settings search", exact: true }).click(); |
| 184 | assert.equal(await navItems.count(), navItemCount, "clearing search restores every navigation item"); |
| 185 | const picker = page.getByRole("button", { name: "Default model", exact: true }); |
| 186 | await picker.click(); |
| 187 | await page.getByRole("listbox", { name: "Default model", exact: true }).waitFor(); |
| 188 | await page.keyboard.press("Escape"); |
| 189 | await page.getByRole("listbox", { name: "Default model", exact: true }).waitFor({ state: "detached" }); |
| 190 | console.log(`PASS ${engineName}/${platform} navigation, assignments and picker`); |
| 191 | await page.locator(".settings-screen .management-screen__back").click(); |
| 192 | } |
| 193 | assert.deepEqual(errors, [], `${engineName}/${platform}: no runtime errors`); |
| 194 | } finally { await browser.close(); } |
| 195 | } |
| 196 | console.log(`PASS settings layout: ${cases} real-page theme/layout/width/zoom cases plus general settings and picker interaction`); |
| 197 | } finally { await new Promise((resolve, reject) => preview.httpServer.close(error => error ? reject(error) : resolve())); } |
| 198 |