| 1 | #!/usr/bin/env node |
| 2 | |
| 3 | import fs from "node:fs"; |
| 4 | import path from "node:path"; |
| 5 | import { fileURLToPath } from "node:url"; |
| 6 | |
| 7 | const frontendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); |
| 8 | process.env.PLAYWRIGHT_BROWSERS_PATH = !process.env.PLAYWRIGHT_BROWSERS_PATH || process.env.PLAYWRIGHT_BROWSERS_PATH === ".pw-browsers" |
| 9 | ? path.join(frontendDir, ".pw-browsers") |
| 10 | : process.env.PLAYWRIGHT_BROWSERS_PATH; |
| 11 | const { chromium } = await import("playwright"); |
| 12 | |
| 13 | const styles = fs.readFileSync(path.join(frontendDir, "src", "styles.css"), "utf8"); |
| 14 | const themeStyles = ["graphite", "aurora", "slate", "carbon", "nocturne", "amber"]; |
| 15 | const modes = [ |
| 16 | { name: "forced dark", attribute: "dark", media: "dark" }, |
| 17 | { name: "forced light", attribute: "light", media: "dark" }, |
| 18 | { name: "auto dark", attribute: null, media: "dark" }, |
| 19 | { name: "auto light", attribute: null, media: "light" }, |
| 20 | ]; |
| 21 | const overlaySelectors = [ |
| 22 | ".anchored-popover", |
| 23 | ".slashmenu", |
| 24 | ".composer-access-menu", |
| 25 | ".jobs-popover", |
| 26 | ".modelsw__menu", |
| 27 | ".workspace-recent-menu", |
| 28 | ".floating-menu", |
| 29 | ".mem-ws-select__menu", |
| 30 | ".menu", |
| 31 | ".settings-select-menu", |
| 32 | ".external-opener__menu", |
| 33 | ".context-menu", |
| 34 | ".theme-gallery__menu", |
| 35 | ".theme-gallery__editor", |
| 36 | ".theme-editor__header", |
| 37 | ".theme-editor__live", |
| 38 | ".provider-access-more__menu", |
| 39 | ".topicbar__export-menu", |
| 40 | ".heartbeat-filter-menu", |
| 41 | ".heartbeat-project-menu", |
| 42 | ".heartbeat-task-menu", |
| 43 | ]; |
| 44 | const surfaceSelectors = [ |
| 45 | ".msg-pasted-block", |
| 46 | ".composer__pasted-block", |
| 47 | ".mem-tab-select", |
| 48 | ".mermaid-diagram__error", |
| 49 | ]; |
| 50 | const fixtures = [...new Set([...overlaySelectors, ...surfaceSelectors])] |
| 51 | .map((selector) => `<div class="${selector.slice(1)}">surface</div>`) |
| 52 | .join(""); |
| 53 | |
| 54 | const amberExpected = { |
| 55 | dark: { |
| 56 | "--bg": "#090a0c", |
| 57 | "--bg-soft": "#111319", |
| 58 | "--panel": "#191b22", |
| 59 | "--sidebar-bg": "#0c0e12", |
| 60 | "--fg": "#f4f5f7", |
| 61 | "--fg-dim": "#c0c4cc", |
| 62 | "--accent": "#d4632f", |
| 63 | "--border": "#343945", |
| 64 | }, |
| 65 | light: { |
| 66 | "--bg": "#f7f8fb", |
| 67 | "--bg-soft": "#eef2f7", |
| 68 | "--panel": "#ffffff", |
| 69 | "--sidebar-bg": "#f3f6fa", |
| 70 | "--fg": "#111827", |
| 71 | "--fg-dim": "#4b5563", |
| 72 | "--accent": "#dd5b28", |
| 73 | "--border": "#d8dee8", |
| 74 | }, |
| 75 | }; |
| 76 | |
| 77 | function assert(condition, message) { |
| 78 | if (!condition) throw new Error(message); |
| 79 | process.stdout.write(` PASS ${message}\n`); |
| 80 | } |
| 81 | |
| 82 | let browser; |
| 83 | try { |
| 84 | browser = await chromium.launch({ headless: true }); |
| 85 | const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); |
| 86 | await page.setContent(`<!doctype html><html><head><style>${styles}</style></head><body>${fixtures}<div class="transcript__loading">muted</div></body></html>`); |
| 87 | |
| 88 | for (const style of themeStyles) { |
| 89 | for (const mode of modes) { |
| 90 | await page.emulateMedia({ colorScheme: mode.media }); |
| 91 | await page.evaluate(({ style, attribute }) => { |
| 92 | const root = document.documentElement; |
| 93 | root.setAttribute("data-theme-style", style); |
| 94 | if (attribute) root.setAttribute("data-theme", attribute); |
| 95 | else root.removeAttribute("data-theme"); |
| 96 | root.removeAttribute("data-theme-pack"); |
| 97 | root.removeAttribute("data-theme-has-bg"); |
| 98 | root.removeAttribute("data-theme-scene"); |
| 99 | root.style.removeProperty("--theme-pane-overlay-pct"); |
| 100 | root.style.removeProperty("--theme-pane-task-overlay-pct"); |
| 101 | }, { style, attribute: mode.attribute }); |
| 102 | |
| 103 | const result = await page.evaluate(({ overlaySelectors, surfaceSelectors }) => { |
| 104 | function colorAlpha(value) { |
| 105 | const canvas = document.createElement("canvas"); |
| 106 | canvas.width = 1; |
| 107 | canvas.height = 1; |
| 108 | const context = canvas.getContext("2d"); |
| 109 | context.clearRect(0, 0, 1, 1); |
| 110 | context.fillStyle = value; |
| 111 | context.fillRect(0, 0, 1, 1); |
| 112 | return context.getImageData(0, 0, 1, 1).data[3] / 255; |
| 113 | } |
| 114 | const alpha = (selector) => colorAlpha(getComputedStyle(document.querySelector(selector)).backgroundColor); |
| 115 | const muted = document.querySelector(".transcript__loading"); |
| 116 | const probe = document.createElement("span"); |
| 117 | probe.style.color = "var(--fg-faint)"; |
| 118 | document.body.append(probe); |
| 119 | const mutedColor = getComputedStyle(muted).color; |
| 120 | const expectedMutedColor = getComputedStyle(probe).color; |
| 121 | probe.remove(); |
| 122 | return { |
| 123 | overlays: Object.fromEntries(overlaySelectors.map((selector) => [selector, alpha(selector)])), |
| 124 | surfaces: Object.fromEntries(surfaceSelectors.map((selector) => [selector, alpha(selector)])), |
| 125 | mutedColor, |
| 126 | expectedMutedColor, |
| 127 | }; |
| 128 | }, { overlaySelectors, surfaceSelectors }); |
| 129 | |
| 130 | for (const [selector, alpha] of Object.entries(result.overlays)) { |
| 131 | assert(alpha >= 0.999, `${style} ${mode.name} keeps ${selector} opaque (${alpha})`); |
| 132 | } |
| 133 | for (const [selector, alpha] of Object.entries(result.surfaces)) { |
| 134 | assert(alpha > 0, `${style} ${mode.name} resolves ${selector} background (${alpha})`); |
| 135 | } |
| 136 | assert(result.mutedColor === result.expectedMutedColor, `${style} ${mode.name} resolves muted text to --fg-faint`); |
| 137 | |
| 138 | if (style === "amber") { |
| 139 | const expected = amberExpected[mode.media === "light" || mode.attribute === "light" ? "light" : "dark"]; |
| 140 | const colors = await page.evaluate((entries) => { |
| 141 | const result = {}; |
| 142 | for (const [token] of entries) { |
| 143 | const probe = document.createElement("span"); |
| 144 | probe.style.backgroundColor = `var(${token})`; |
| 145 | document.body.append(probe); |
| 146 | result[token] = getComputedStyle(probe).backgroundColor; |
| 147 | probe.remove(); |
| 148 | } |
| 149 | return result; |
| 150 | }, Object.entries(expected)); |
| 151 | const expectedColors = await page.evaluate((entries) => { |
| 152 | const result = {}; |
| 153 | for (const [token, value] of entries) { |
| 154 | const probe = document.createElement("span"); |
| 155 | probe.style.backgroundColor = value; |
| 156 | document.body.append(probe); |
| 157 | result[token] = getComputedStyle(probe).backgroundColor; |
| 158 | probe.remove(); |
| 159 | } |
| 160 | return result; |
| 161 | }, Object.entries(expected)); |
| 162 | for (const token of Object.keys(expected)) { |
| 163 | assert(colors[token] === expectedColors[token], `Amber ${mode.name} ${token} matches preview (${colors[token]})`); |
| 164 | } |
| 165 | } |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | await page.emulateMedia({ colorScheme: "dark" }); |
| 170 | for (const [paneOpacity, expectedAlpha] of [[0, 0.4], [0.5, 0.9], [1, 1]]) { |
| 171 | for (const scene of ["home", "task"]) { |
| 172 | const alphas = await page.evaluate(({ overlaySelectors, paneOpacity, scene }) => { |
| 173 | const root = document.documentElement; |
| 174 | const percentage = `${Math.min((paneOpacity + 0.4) * 100, 100)}%`; |
| 175 | root.setAttribute("data-theme-style", "graphite"); |
| 176 | root.setAttribute("data-theme", "dark"); |
| 177 | root.setAttribute("data-theme-pack", "surface-contract"); |
| 178 | root.setAttribute("data-theme-has-bg", "true"); |
| 179 | root.setAttribute("data-theme-scene", scene); |
| 180 | root.style.setProperty("--theme-pane-overlay-pct", percentage); |
| 181 | root.style.setProperty("--theme-pane-task-overlay-pct", percentage); |
| 182 | return Object.fromEntries(overlaySelectors.map((selector) => { |
| 183 | const value = getComputedStyle(document.querySelector(selector)).backgroundColor; |
| 184 | const canvas = document.createElement("canvas"); |
| 185 | canvas.width = 1; |
| 186 | canvas.height = 1; |
| 187 | const context = canvas.getContext("2d"); |
| 188 | context.clearRect(0, 0, 1, 1); |
| 189 | context.fillStyle = value; |
| 190 | context.fillRect(0, 0, 1, 1); |
| 191 | return [selector, context.getImageData(0, 0, 1, 1).data[3] / 255]; |
| 192 | })); |
| 193 | }, { overlaySelectors, paneOpacity, scene }); |
| 194 | for (const [selector, alpha] of Object.entries(alphas)) { |
| 195 | assert( |
| 196 | Math.abs(alpha - expectedAlpha) <= 1 / 255, |
| 197 | `${scene} ${selector} follows paneOpacity ${paneOpacity} (${alpha})`, |
| 198 | ); |
| 199 | } |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | process.stdout.write("\nTheme surface browser contract passed.\n"); |
| 204 | } finally { |
| 205 | await browser?.close(); |
| 206 | } |
| 207 |