diff --git a/e2e/ui/__screenshots__/linux/desktop/workflows.spec.ts/article-form.png b/e2e/ui/__screenshots__/linux/desktop-chromium/workflows.spec.ts/article-form.png similarity index 100% rename from e2e/ui/__screenshots__/linux/desktop/workflows.spec.ts/article-form.png rename to e2e/ui/__screenshots__/linux/desktop-chromium/workflows.spec.ts/article-form.png diff --git a/e2e/ui/__screenshots__/linux/desktop/workflows.spec.ts/article-preview.png b/e2e/ui/__screenshots__/linux/desktop-chromium/workflows.spec.ts/article-preview.png similarity index 100% rename from e2e/ui/__screenshots__/linux/desktop/workflows.spec.ts/article-preview.png rename to e2e/ui/__screenshots__/linux/desktop-chromium/workflows.spec.ts/article-preview.png diff --git a/e2e/ui/__screenshots__/linux/desktop/workflows.spec.ts/settings-switches.png b/e2e/ui/__screenshots__/linux/desktop-chromium/workflows.spec.ts/settings-switches.png similarity index 100% rename from e2e/ui/__screenshots__/linux/desktop/workflows.spec.ts/settings-switches.png rename to e2e/ui/__screenshots__/linux/desktop-chromium/workflows.spec.ts/settings-switches.png diff --git a/e2e/ui/__screenshots__/linux/desktop/workflows.spec.ts/sync-history.png b/e2e/ui/__screenshots__/linux/desktop-chromium/workflows.spec.ts/sync-history.png similarity index 100% rename from e2e/ui/__screenshots__/linux/desktop/workflows.spec.ts/sync-history.png rename to e2e/ui/__screenshots__/linux/desktop-chromium/workflows.spec.ts/sync-history.png diff --git a/e2e/ui/__screenshots__/linux/desktop/workflows.spec.ts/ticket-saved-views.png b/e2e/ui/__screenshots__/linux/desktop-chromium/workflows.spec.ts/ticket-saved-views.png similarity index 100% rename from e2e/ui/__screenshots__/linux/desktop/workflows.spec.ts/ticket-saved-views.png rename to e2e/ui/__screenshots__/linux/desktop-chromium/workflows.spec.ts/ticket-saved-views.png diff --git a/e2e/ui/__screenshots__/linux/desktop/workflows.spec.ts/ticket-table.png b/e2e/ui/__screenshots__/linux/desktop-chromium/workflows.spec.ts/ticket-table.png similarity index 100% rename from e2e/ui/__screenshots__/linux/desktop/workflows.spec.ts/ticket-table.png rename to e2e/ui/__screenshots__/linux/desktop-chromium/workflows.spec.ts/ticket-table.png diff --git a/e2e/ui/__screenshots__/linux/mobile/workflows.spec.ts/article-form.png b/e2e/ui/__screenshots__/linux/mobile-chromium/workflows.spec.ts/article-form.png similarity index 100% rename from e2e/ui/__screenshots__/linux/mobile/workflows.spec.ts/article-form.png rename to e2e/ui/__screenshots__/linux/mobile-chromium/workflows.spec.ts/article-form.png diff --git a/e2e/ui/__screenshots__/linux/mobile/workflows.spec.ts/article-preview.png b/e2e/ui/__screenshots__/linux/mobile-chromium/workflows.spec.ts/article-preview.png similarity index 100% rename from e2e/ui/__screenshots__/linux/mobile/workflows.spec.ts/article-preview.png rename to e2e/ui/__screenshots__/linux/mobile-chromium/workflows.spec.ts/article-preview.png diff --git a/e2e/ui/__screenshots__/linux/mobile/workflows.spec.ts/settings-switches.png b/e2e/ui/__screenshots__/linux/mobile-chromium/workflows.spec.ts/settings-switches.png similarity index 100% rename from e2e/ui/__screenshots__/linux/mobile/workflows.spec.ts/settings-switches.png rename to e2e/ui/__screenshots__/linux/mobile-chromium/workflows.spec.ts/settings-switches.png diff --git a/e2e/ui/__screenshots__/linux/mobile/workflows.spec.ts/sync-history.png b/e2e/ui/__screenshots__/linux/mobile-chromium/workflows.spec.ts/sync-history.png similarity index 100% rename from e2e/ui/__screenshots__/linux/mobile/workflows.spec.ts/sync-history.png rename to e2e/ui/__screenshots__/linux/mobile-chromium/workflows.spec.ts/sync-history.png diff --git a/e2e/ui/__screenshots__/linux/mobile/workflows.spec.ts/ticket-saved-views.png b/e2e/ui/__screenshots__/linux/mobile-chromium/workflows.spec.ts/ticket-saved-views.png similarity index 100% rename from e2e/ui/__screenshots__/linux/mobile/workflows.spec.ts/ticket-saved-views.png rename to e2e/ui/__screenshots__/linux/mobile-chromium/workflows.spec.ts/ticket-saved-views.png diff --git a/e2e/ui/__screenshots__/linux/mobile/workflows.spec.ts/ticket-table.png b/e2e/ui/__screenshots__/linux/mobile-chromium/workflows.spec.ts/ticket-table.png similarity index 100% rename from e2e/ui/__screenshots__/linux/mobile/workflows.spec.ts/ticket-table.png rename to e2e/ui/__screenshots__/linux/mobile-chromium/workflows.spec.ts/ticket-table.png diff --git a/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/article-form.png b/e2e/ui/__screenshots__/win32/desktop-chromium/workflows.spec.ts/article-form.png similarity index 100% rename from e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/article-form.png rename to e2e/ui/__screenshots__/win32/desktop-chromium/workflows.spec.ts/article-form.png diff --git a/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/article-preview.png b/e2e/ui/__screenshots__/win32/desktop-chromium/workflows.spec.ts/article-preview.png similarity index 100% rename from e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/article-preview.png rename to e2e/ui/__screenshots__/win32/desktop-chromium/workflows.spec.ts/article-preview.png diff --git a/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/settings-switches.png b/e2e/ui/__screenshots__/win32/desktop-chromium/workflows.spec.ts/settings-switches.png similarity index 100% rename from e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/settings-switches.png rename to e2e/ui/__screenshots__/win32/desktop-chromium/workflows.spec.ts/settings-switches.png diff --git a/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/sync-history.png b/e2e/ui/__screenshots__/win32/desktop-chromium/workflows.spec.ts/sync-history.png similarity index 100% rename from e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/sync-history.png rename to e2e/ui/__screenshots__/win32/desktop-chromium/workflows.spec.ts/sync-history.png diff --git a/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/ticket-saved-views.png b/e2e/ui/__screenshots__/win32/desktop-chromium/workflows.spec.ts/ticket-saved-views.png similarity index 100% rename from e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/ticket-saved-views.png rename to e2e/ui/__screenshots__/win32/desktop-chromium/workflows.spec.ts/ticket-saved-views.png diff --git a/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/ticket-table.png b/e2e/ui/__screenshots__/win32/desktop-chromium/workflows.spec.ts/ticket-table.png similarity index 100% rename from e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/ticket-table.png rename to e2e/ui/__screenshots__/win32/desktop-chromium/workflows.spec.ts/ticket-table.png diff --git a/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/article-form.png b/e2e/ui/__screenshots__/win32/mobile-chromium/workflows.spec.ts/article-form.png similarity index 100% rename from e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/article-form.png rename to e2e/ui/__screenshots__/win32/mobile-chromium/workflows.spec.ts/article-form.png diff --git a/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/article-preview.png b/e2e/ui/__screenshots__/win32/mobile-chromium/workflows.spec.ts/article-preview.png similarity index 100% rename from e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/article-preview.png rename to e2e/ui/__screenshots__/win32/mobile-chromium/workflows.spec.ts/article-preview.png diff --git a/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/settings-switches.png b/e2e/ui/__screenshots__/win32/mobile-chromium/workflows.spec.ts/settings-switches.png similarity index 100% rename from e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/settings-switches.png rename to e2e/ui/__screenshots__/win32/mobile-chromium/workflows.spec.ts/settings-switches.png diff --git a/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/sync-history.png b/e2e/ui/__screenshots__/win32/mobile-chromium/workflows.spec.ts/sync-history.png similarity index 100% rename from e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/sync-history.png rename to e2e/ui/__screenshots__/win32/mobile-chromium/workflows.spec.ts/sync-history.png diff --git a/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/ticket-saved-views.png b/e2e/ui/__screenshots__/win32/mobile-chromium/workflows.spec.ts/ticket-saved-views.png similarity index 100% rename from e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/ticket-saved-views.png rename to e2e/ui/__screenshots__/win32/mobile-chromium/workflows.spec.ts/ticket-saved-views.png diff --git a/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/ticket-table.png b/e2e/ui/__screenshots__/win32/mobile-chromium/workflows.spec.ts/ticket-table.png similarity index 100% rename from e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/ticket-table.png rename to e2e/ui/__screenshots__/win32/mobile-chromium/workflows.spec.ts/ticket-table.png diff --git a/e2e/ui/fixture-server.mjs b/e2e/ui/fixture-server.mjs index 98debb57..7c0b95a6 100644 --- a/e2e/ui/fixture-server.mjs +++ b/e2e/ui/fixture-server.mjs @@ -1,4 +1,5 @@ -import { readFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises"; import { createServer } from "node:http"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -24,63 +25,153 @@ const actions = new Set([ "@/actions/admin-media", ]); const nodeStubs = path.join(root, "e2e/ui/node-stubs.mjs"); -const bundle = await build({ - entryPoints: [path.join(fixtures, "entry.tsx")], - bundle: true, - write: false, - outdir: path.join(root, "test-results/ui-fixture"), - format: "iife", - jsx: "automatic", - alias: { - "@": path.join(root, "src"), - "node:async_hooks": nodeStubs, - "node:crypto": nodeStubs, - "node:fs/promises": nodeStubs, - "node:path": nodeStubs, - }, - define: { "process.env.NODE_ENV": '"production"' }, - plugins: [ - { - name: "server-contracts", - setup(build) { - build.onResolve( - { filter: /^next\/navigation$|^next\/link$|^@\/actions\// }, - (args) => { - if (args.path === "next/navigation") - return { path: path.join(fixtures, "navigation.ts") }; - if (args.path === "next/link") - return { path: path.join(fixtures, "next-link.tsx") }; - if (actions.has(args.path)) - return { path: path.join(fixtures, "server-actions.ts") }; - throw Error(`Unexpected server action in UI fixture: ${args.path}`); - }, - ); - build.onResolve( - { filter: /^@\/lib\/db$|^mysql2|^next\/headers$/ }, - (args) => { - throw Error( - `Database or server dependency forbidden in UI fixture: ${args.path}`, +const cacheDir = path.join(root, "node_modules/.cache/nexst-ui-fixture"); +const startedAt = Date.now(); + +async function listFiles(dir) { + const entries = await readdir(dir, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const target = path.join(dir, entry.name); + if (entry.isDirectory()) files.push(...(await listFiles(target))); + else files.push(target); + } + return files; +} + +async function fixtureCacheKey() { + const files = [ + ...(await listFiles(path.join(root, "src"))), + ...(await listFiles(fixtures)), + nodeStubs, + path.join(root, "e2e/ui/process-shim.mjs"), + fileURLToPath(import.meta.url), + path.join(root, "package.json"), + ].sort(); + const hash = createHash("sha256"); + for (const file of files) { + hash.update(file.slice(root.length)); + hash.update(await readFile(file)); + } + return hash.digest("hex"); +} + +async function readCache(key) { + try { + const [js, css] = await Promise.all([ + readFile(path.join(cacheDir, `${key}.app.js`), "utf8"), + readFile(path.join(cacheDir, `${key}.styles.css`), "utf8"), + ]); + return { js, css }; + } catch { + return null; + } +} + +async function writeCache(key, js, css) { + await mkdir(cacheDir, { recursive: true }); + await Promise.all([ + writeFile(path.join(cacheDir, `${key}.app.js`), js), + writeFile(path.join(cacheDir, `${key}.styles.css`), css), + ]); + const stale = await readdir(cacheDir); + await Promise.all( + stale + .filter((name) => !name.startsWith(key)) + .map((name) => unlink(path.join(cacheDir, name))), + ); +} + +async function buildFixture() { + const [bundle, globalCss] = await Promise.all([ + build({ + entryPoints: [path.join(fixtures, "entry.tsx")], + bundle: true, + write: false, + outdir: path.join(root, "test-results/ui-fixture"), + format: "iife", + jsx: "automatic", + alias: { + "@": path.join(root, "src"), + "node:async_hooks": nodeStubs, + "node:child_process": nodeStubs, + "node:crypto": nodeStubs, + "node:dns/promises": nodeStubs, + "node:fs": nodeStubs, + "node:fs/promises": nodeStubs, + "node:http": nodeStubs, + "node:net": nodeStubs, + "node:os": nodeStubs, + "node:path": nodeStubs, + "node:url": nodeStubs, + "node:util": nodeStubs, + }, + define: { + "process.env.NODE_ENV": '"production"', + "process.env.SKIP_ENV_VALIDATION": '"1"', + }, + inject: [path.join(root, "e2e/ui/process-shim.mjs")], + plugins: [ + { + name: "server-contracts", + setup(build) { + build.onResolve( + { filter: /^next\/navigation$|^next\/link$|^@\/actions\// }, + (args) => { + if (args.path === "next/navigation") + return { path: path.join(fixtures, "navigation.ts") }; + if (args.path === "next/link") + return { path: path.join(fixtures, "next-link.tsx") }; + if (actions.has(args.path)) + return { path: path.join(fixtures, "server-actions.ts") }; + throw Error( + `Unexpected server action in UI fixture: ${args.path}`, + ); + }, + ); + build.onResolve( + { filter: /^@\/lib\/db$|^mysql2|^next\/headers$/ }, + (args) => { + throw Error( + `Database or server dependency forbidden in UI fixture: ${args.path}`, + ); + }, ); }, - ); - }, - }, - ], -}); -const globalCss = ( - await postcss([tailwind({ base: root })]).process( - await readFile(path.join(root, "src/app/globals.css"), "utf8"), - { from: path.join(root, "src/app/globals.css") }, - ) -).css; -const css = - globalCss + - "\n" + - bundle.outputFiles - .filter((file) => file.path.endsWith(".css")) - .map((file) => file.text) - .join("\n"); -const js = bundle.outputFiles.find((file) => file.path.endsWith(".js")).text; + }, + ], + }), + postcss([tailwind({ base: root })]) + .process(await readFile(path.join(root, "src/app/globals.css"), "utf8"), { + from: path.join(root, "src/app/globals.css"), + }) + .then((result) => result.css), + ]); + const js = bundle.outputFiles.find((file) => file.path.endsWith(".js")).text; + const css = + globalCss + + "\n" + + bundle.outputFiles + .filter((file) => file.path.endsWith(".css")) + .map((file) => file.text) + .join("\n"); + return { js, css }; +} + +const cacheKey = await fixtureCacheKey(); +let assets = await readCache(cacheKey); +if (assets) { + console.log( + `[fixture] reusing cached bundle ${cacheKey.slice(0, 12)} (${Date.now() - startedAt}ms)`, + ); +} else { + assets = await buildFixture(); + await writeCache(cacheKey, assets.js, assets.css); + console.log( + `[fixture] built bundle ${cacheKey.slice(0, 12)} in ${Date.now() - startedAt}ms`, + ); +} +const { js, css } = assets; const html = ' ({ update: () => ({ digest: () => "", }), - }; -} + }), + randomUUID: () => "00000000-0000-0000-0000-000000000000", + randomBytes: (size) => new Uint8Array(size), + createHmac: () => ({ + update: () => ({ + digest: () => "", + }), + }), + timingSafeEqual: () => true, + createCipheriv: () => ({ + update: () => new Uint8Array(0), + final: () => new Uint8Array(0), + }), + createDecipheriv: () => ({ + update: () => new Uint8Array(0), + final: () => new Uint8Array(0), + }), + webcrypto: globalThis.crypto, +}; -export function randomUUID() { - return "00000000-0000-0000-0000-000000000000"; -} +export const { + createHash, + randomUUID, + randomBytes, + createHmac, + timingSafeEqual, + createCipheriv, + createDecipheriv, + webcrypto, +} = cryptoApi; -export async function readFile() { - return Buffer.alloc(0); -} +export const appendFile = async () => {}; +export const mkdir = async () => {}; +export const readdir = async () => []; +export const readFile = async () => new Uint8Array(0); +export const rename = async () => {}; +export const stat = async () => ({ + isDirectory: () => false, + isFile: () => true, + size: 0, + mtimeMs: 0, +}); +export const unlink = async () => {}; +export const writeFile = async () => {}; +export const rm = async () => {}; +export const mkdtemp = async () => "/tmp/fixture-stub"; -export const path = { +export const promises = { + appendFile, + mkdir, + readdir, + readFile, + rename, + stat, + unlink, + writeFile, + rm, + mkdtemp, +}; + +export const existsSync = () => false; +export const readFileSync = () => new Uint8Array(0); +export const writeFileSync = () => {}; +export const readdirSync = () => []; +export const mkdirSync = () => {}; +export const rmSync = () => {}; +export const mkdtempSync = () => "/tmp/fixture-stub"; +export const appendFileSync = () => {}; +export const statSync = () => ({ + isDirectory: () => false, + isFile: () => true, + size: 0, +}); + +const pathApi = { join: (...parts) => parts.join("/"), resolve: (...parts) => parts.join("/"), + dirname: (value) => value.split("/").slice(0, -1).join("/"), + basename: (value) => value.split("/").pop() ?? "", + extname: (value) => { + const base = value.split("/").pop() ?? ""; + const index = base.lastIndexOf("."); + return index > 0 ? base.slice(index) : ""; + }, + relative: (_from, to) => to, + normalize: (value) => value, + isAbsolute: (value) => value.startsWith("/"), sep: "/", + delimiter: ":", +}; + +export const { + join, + resolve, + dirname, + basename, + extname, + relative, + normalize, + isAbsolute, + sep, + delimiter, +} = pathApi; + +export const path = pathApi; + +const osApi = { + hostname: () => "localhost", + tmpdir: () => "/tmp", + platform: () => "linux", + homedir: () => "/tmp", +}; + +export const { hostname, tmpdir, platform, homedir } = osApi; + +export const promisify = (fn) => fn; +export const isIP = () => 0; +export const lookup = async () => ({ address: "127.0.0.1", family: 4 }); +export const execFile = () => {}; +export const execFileSync = () => ""; +export const spawnSync = () => ({ status: 0, stdout: "", stderr: "" }); +export const fileURLToPath = (value) => String(value); +export const createServer = () => ({ + listen: () => {}, + close: () => {}, + on: () => {}, + address: () => ({ port: 0 }), +}); + +// Single default export: all node:* aliases point at this one file, and the +// merged object keeps `import crypto from "node:crypto"`, `import path from +// "node:path"` and `import os from "node:os"` usable after aliasing. +export default { + ...cryptoApi, + ...pathApi, + ...osApi, + appendFile, + mkdir, + readdir, + readFile, + rename, + stat, + unlink, + writeFile, + rm, + mkdtemp, + promises, + existsSync, + readFileSync, + writeFileSync, + readdirSync, + mkdirSync, + rmSync, + mkdtempSync, + appendFileSync, + statSync, + promisify, + isIP, + lookup, + execFile, + execFileSync, + spawnSync, + fileURLToPath, + createServer, }; diff --git a/e2e/ui/process-shim.mjs b/e2e/ui/process-shim.mjs new file mode 100644 index 00000000..7d24f460 --- /dev/null +++ b/e2e/ui/process-shim.mjs @@ -0,0 +1,25 @@ +// Minimal `process` shim injected into the UI fixture bundle. Server-only +// modules that leak into the bundle reference the `process` global at module +// scope (release ids, paths, etc). esbuild's `inject` rewrites those free +// identifier references to import from this file so evaluation cannot crash +// in the browser. + +export const process = { + env: { NODE_ENV: "production" }, + platform: "linux", + arch: "x64", + version: "v20.0.0", + versions: { node: "20.0.0" }, + argv: [], + cwd: () => "/", + nextTick: (fn, ...args) => setTimeout(() => fn(...args), 0), + on: () => {}, + once: () => {}, + emit: () => {}, + exit: () => {}, + hrtime: Object.assign(() => [0, 0], { bigint: () => 0n }), + memoryUsage: () => ({ heapUsed: 0, rss: 0 }), + pid: 0, +}; + +export default process;