// Run: node scripts/shader-pixel-regression.mjs // Optional: SHADER_BROWSER=/path/to/firefox SHADER_TIMEOUT_MS=180000 // Chromium: SHADER_BROWSER=/path/to/chrome-headless-shell SHADER_BROWSER_KIND=chromium // Hosts without user namespaces: SHADER_NO_SANDBOX=1 (local test content only). // No automation dependency: the browser POSTs WebGL readback results to a loopback Vite server. // Also checks that prepared packs create no new programs through reveal and restart. import { spawn } from 'node:child_process' import { once } from 'node:events' import { readFile, mkdir, writeFile, rm } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { createServer } from 'vite' import { originalFragment, baselineFragment, referenceCommit, referenceHash } from './shader-wear-reference.mjs' const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') const profile = resolve(root, 'scripts', `.shader-browser-${process.pid}`) const timeoutMs = Number(process.env.SHADER_TIMEOUT_MS ?? 180000) if (!Number.isFinite(timeoutMs) || timeoutMs < 1000 || timeoutMs > 600000) { throw new Error('SHADER_TIMEOUT_MS must be between 1000 and 600000') } const fragmentShader = originalFragment(await readFile(resolve(root, 'src/cardMaterial.ts'), 'utf8')) const baselineShader = baselineFragment(root) let resolveResult let rejectResult const result = new Promise((resolve, reject) => { resolveResult = resolve rejectResult = reject }) // Attach a handler immediately; startup errors can precede awaiting the result. result.catch(() => {}) let server let browser let timer try { await mkdir(profile) await writeFile(resolve(profile, 'user.js'), [ 'user_pref("webgl.force-enabled", true);', 'user_pref("webgl.disabled", false);', 'user_pref("browser.shell.checkDefaultBrowser", false);', 'user_pref("browser.startup.homepage_override.mstone", "ignore");', 'user_pref("datareporting.policy.dataSubmissionEnabled", false);', ].join('\n')) server = await createServer({ root, configFile: false, cacheDir: resolve(profile, 'vite'), logLevel: 'error', server: { host: '127.0.0.1', port: 0, watch: null }, plugins: [{ name: 'shader-pixel-regression', configureServer(vite) { vite.middlewares.use((req, res, next) => { if (req.url === '/__shader_reference') { res.setHeader('Content-Type', 'application/json') res.end(JSON.stringify({ fragmentShader, baselineShader })) } else if (req.url === '/__shader_pixels' && req.method === 'POST') { let body = '' req.on('data', (chunk) => { body += chunk if (body.length > 2_000_000) req.destroy() }) req.on('end', () => { try { resolveResult(JSON.parse(body)) res.end('ok') } catch (error) { rejectResult(error) res.statusCode = 400 res.end('Invalid JSON') } }) } else if (req.url === '/__shader_test') { res.setHeader('Content-Type', 'text/html') res.end('Shader pixel regression') } else { next() } }) }, }], }) await server.listen() const address = server.httpServer.address() const url = `http://127.0.0.1:${address.port}/__shader_test` const probe = await fetch(url) if (!probe.ok) throw new Error(`Local test server failed: ${probe.status}`) console.log(`Shader reference verified against ${referenceCommit} (${referenceHash})`) console.log(`Serving ${url}; browser timeout ${timeoutMs}ms`) timer = setTimeout(() => rejectResult(new Error('Browser pixel regression timed out')), timeoutMs) const browserArgs = process.env.SHADER_BROWSER_KIND === 'chromium' ? [ '--headless', `--user-data-dir=${profile}`, '--no-first-run', '--disable-background-networking', '--disable-dev-shm-usage', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', url, ] : ['--headless', '--no-remote', '--profile', profile, url] if (process.env.SHADER_BROWSER_KIND === 'chromium' && process.env.SHADER_NO_SANDBOX === '1') { browserArgs.unshift('--no-sandbox') } browser = spawn(process.env.SHADER_BROWSER ?? '/usr/bin/firefox', browserArgs, { env: { ...process.env, TMPDIR: profile, MOZ_HEADLESS: '1' }, stdio: ['ignore', 'ignore', 'pipe'], }) let browserLog = '' browser.stderr.on('data', (chunk) => { browserLog = (browserLog + chunk).slice(-12000) }) browser.once('error', rejectResult) browser.once('exit', (code) => rejectResult(new Error(`Browser exited with ${code}\n${browserLog}`))) let report try { report = await result } catch (error) { console.error(browserLog) throw error } console.log(JSON.stringify({ referenceCommit, referenceHash, ...report }, null, 2)) if (!report.passed) process.exitCode = 1 } finally { clearTimeout(timer) if (browser?.pid && browser.exitCode === null && browser.signalCode === null) { const exited = once(browser, 'exit') browser.kill('SIGTERM') const forceKill = setTimeout(() => browser.kill('SIGKILL'), 3000) await exited clearTimeout(forceKill) } await server?.close() await rm(profile, { recursive: true, force: true }) }