| 1 | import assert from "node:assert/strict"; |
| 2 | import { mkdtemp, rm } from "node:fs/promises"; |
| 3 | import { tmpdir } from "node:os"; |
| 4 | import path from "node:path"; |
| 5 | import { fileURLToPath } from "node:url"; |
| 6 | import { build, preview, loadConfigFromFile } from "vite"; |
| 7 | |
| 8 | const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); |
| 9 | process.env.PLAYWRIGHT_BROWSERS_PATH ||= path.join(root, ".pw-browsers"); |
| 10 | const { chromium } = await import("playwright"); |
| 11 | const outDir = await mkdtemp(path.join(tmpdir(), "reasonix-code-theme-")); |
| 12 | const { config } = await loadConfigFromFile({ command: "build", mode: "production" }, path.join(root, "vite.config.ts")); |
| 13 | let server, browser; |
| 14 | try { |
| 15 | await build({ ...config, configFile: false, root, logLevel: "error", |
| 16 | plugins: config.plugins.filter(plugin => !["archive-hidden-sourcemaps", "keep-dist-placeholder"].includes(plugin?.name)), |
| 17 | build: { ...config.build, outDir, sourcemap: false, |
| 18 | rolldownOptions: { ...config.build.rolldownOptions, input: path.join(root, "bench/code-theme.html") } } }); |
| 19 | server = await preview({ configFile: false, root, logLevel: "error", build: { outDir }, preview: { host: "127.0.0.1", port: 0 } }); |
| 20 | browser = await chromium.launch({ headless: true }); |
| 21 | const page = await browser.newPage({ viewport: { width: 1100, height: 900 }, permissions: ["clipboard-read", "clipboard-write"] }); |
| 22 | const errors = []; |
| 23 | page.on("pageerror", error => errors.push(error.message)); |
| 24 | await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/bench/code-theme.html?mock=1`); |
| 25 | const live = page.getByRole("region", { name: "Streaming code" }); |
| 26 | await live.locator(".hljs-keyword").first().waitFor(); |
| 27 | const prefix = await live.locator("pre").textContent(); |
| 28 | await live.locator(".code-block__line").first().evaluate(el => { |
| 29 | el.dataset.retained = "yes"; |
| 30 | const range = document.createRange(); range.selectNodeContents(el); |
| 31 | window.getSelection().removeAllRanges(); window.getSelection().addRange(range); |
| 32 | }); |
| 33 | await page.getByRole("button", { name: "追加代码" }).click(); |
| 34 | await live.getByText("count", { exact: true }).count(); |
| 35 | await page.waitForFunction(() => document.querySelector('[aria-label="Streaming code"] pre').textContent.includes("42")); |
| 36 | assert.equal(await live.locator(".code-block__line").first().getAttribute("data-retained"), "yes"); |
| 37 | assert.ok((await live.locator("pre").textContent()).startsWith(prefix)); |
| 38 | await page.getByRole("button", { name: "追加代码" }).click(); |
| 39 | await live.getByRole("button", { name: /Copy|复制/, exact: false }).click(); |
| 40 | const complete = await live.locator("pre").textContent(); |
| 41 | assert.equal(await page.evaluate(() => navigator.clipboard.readText()), complete, "copy excludes header and fence markers"); |
| 42 | await page.getByRole("button", { name: "追加代码" }).click(); |
| 43 | await page.waitForFunction(() => document.querySelector('[aria-label="Streaming code"] .hljs-keyword')); |
| 44 | assert.equal(await live.locator("pre").textContent(), complete, "settlement preserves code text"); |
| 45 | |
| 46 | async function checkContrast(label) { |
| 47 | await page.locator('[aria-label="Completed code"] .hljs-string').first().waitFor(); |
| 48 | const colors = await page.locator('[aria-label="Completed code"] [class^="hljs-"]').evaluateAll(tokens => |
| 49 | [...new Set(tokens.map(token => getComputedStyle(token).color))]); |
| 50 | assert.ok(colors.length >= 4, `${label}: syntax roles must actually have distinct colors, got ${colors}`); |
| 51 | const measurements = await page.evaluate(() => { |
| 52 | const luminance = color => { |
| 53 | const rgb = color.match(/[\d.]+/g).slice(0, 3).map(Number).map(v => { |
| 54 | v /= 255; return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; |
| 55 | }); |
| 56 | return rgb[0] * .2126 + rgb[1] * .7152 + rgb[2] * .0722; |
| 57 | }; |
| 58 | const ratio = (a, b) => (Math.max(a, b) + .05) / (Math.min(a, b) + .05); |
| 59 | return [...document.querySelectorAll(".code-block--framed")].flatMap(block => { |
| 60 | const pre = block.querySelector("pre"); |
| 61 | const header = block.querySelector(".code-block__header"); |
| 62 | return [...pre.querySelectorAll('[class^="hljs-"]'), pre, header, header.querySelector("button")].map(el => { |
| 63 | const bg = getComputedStyle(el.closest("pre") ?? el).backgroundColor; |
| 64 | return { token: el.className, bg, ratio: ratio(luminance(getComputedStyle(el).color), luminance(bg)) }; |
| 65 | }); |
| 66 | }); |
| 67 | }); |
| 68 | for (const value of measurements) { |
| 69 | assert.ok(!value.bg.startsWith("rgba"), `${label} ${value.token}: code surface must be opaque`); |
| 70 | assert.ok(value.ratio >= 4.49, `${label} ${value.token}: contrast ${value.ratio}`); |
| 71 | } |
| 72 | return Math.min(...measurements.map(value => value.ratio)); |
| 73 | } |
| 74 | let minimum = Infinity; |
| 75 | let cases = 0; |
| 76 | for (const style of ["graphite", "aurora", "slate", "carbon", "nocturne", "amber"]) { |
| 77 | await page.getByLabel("Style", { exact: true }).selectOption(style); |
| 78 | for (const [mode, system] of [["light", "dark"], ["dark", "light"], ["auto", "light"], ["auto", "dark"]]) { |
| 79 | await page.emulateMedia({ colorScheme: system }); |
| 80 | await page.getByLabel("Mode", { exact: true }).selectOption(mode); |
| 81 | minimum = Math.min(minimum, await checkContrast(`${style}/${mode}/${system}`)); cases++; |
| 82 | } |
| 83 | } |
| 84 | for (const pack of ["inverted", "midtone", "translucent", "wallpaper"]) { |
| 85 | await page.getByLabel("Palette", { exact: true }).selectOption(pack); |
| 86 | for (const mode of ["light", "dark"]) { |
| 87 | await page.getByLabel("Mode", { exact: true }).selectOption(mode); |
| 88 | minimum = Math.min(minimum, await checkContrast(`${pack}/${mode}`)); cases++; |
| 89 | } |
| 90 | } |
| 91 | await page.getByLabel("Scene", { exact: true }).selectOption("home"); |
| 92 | minimum = Math.min(minimum, await checkContrast("wallpaper/home")); cases++; |
| 93 | const spacing = await page.locator(".code-block--framed pre").first().evaluate(el => getComputedStyle(el).margin); |
| 94 | assert.equal(spacing, "0px", "Markdown default pre margins cannot split the framed surface"); |
| 95 | await page.setViewportSize({ width: 390, height: 844 }); |
| 96 | const overflow = await page.evaluate(() => document.documentElement.scrollWidth > innerWidth); |
| 97 | assert.equal(overflow, false, "narrow viewport does not overflow horizontally"); |
| 98 | assert.deepEqual(errors, []); |
| 99 | console.log(JSON.stringify({ cases, minimumContrast: minimum, streaming: "passed", copy: "passed", narrow: "passed", errors })); |
| 100 | } finally { |
| 101 | await browser?.close(); |
| 102 | await new Promise(resolve => server ? server.httpServer.close(resolve) : resolve()); |
| 103 | await rm(outDir, { recursive: true, force: true }); |
| 104 | } |
| 105 |