comparison mrjunejune/test/theme_and_webp_test.js @ 241:9c2eec61a152

[assets] Generate site images as WebP with Bazel
author MrJuneJune <me@mrjunejune.com>
date Mon, 03 Aug 2026 11:26:30 -0700
parents
children 543df0fe7168
comparison
equal deleted inserted replaced
240:c345083cd8a7 241:9c2eec61a152
1 const assert = require('node:assert/strict');
2 const fs = require('node:fs');
3 const net = require('node:net');
4 const path = require('node:path');
5 const { spawn } = require('node:child_process');
6
7 const RUNFILES = process.env.JS_BINARY__RUNFILES;
8 const WORKSPACE = process.env.JS_BINARY__WORKSPACE;
9 let port;
10 let baseUrl;
11 const playwrightPath = path.join(
12 RUNFILES,
13 WORKSPACE,
14 'hg-web/e2e/node_modules/playwright-core',
15 );
16 const { chromium } = require(playwrightPath);
17
18 function stopProcess(child) {
19 if (!child || child.exitCode !== null) return Promise.resolve();
20 child.kill('SIGTERM');
21 return new Promise(resolve => {
22 const timer = setTimeout(() => {
23 if (child.exitCode === null) child.kill('SIGKILL');
24 }, 3000);
25 child.once('exit', () => {
26 clearTimeout(timer);
27 resolve();
28 });
29 });
30 }
31
32 async function waitForServer(child, logs) {
33 const deadline = Date.now() + 15000;
34 while (Date.now() < deadline) {
35 if (child.exitCode !== null) {
36 throw new Error(`Server exited with ${child.exitCode}\n${logs.join('')}`);
37 }
38 try {
39 const response = await fetch(baseUrl);
40 if (response.ok) return;
41 } catch {
42 // Keep waiting for startup.
43 }
44 await new Promise(resolve => setTimeout(resolve, 100));
45 }
46 throw new Error(`Server startup timed out\n${logs.join('')}`);
47 }
48
49 function listFiles(root) {
50 const files = [];
51 for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
52 const absolute = path.join(root, entry.name);
53 if (entry.isDirectory()) files.push(...listFiles(absolute));
54 else files.push(absolute);
55 }
56 return files;
57 }
58
59 async function sampleTheme(browser, theme) {
60 const context = await browser.newContext({ colorScheme: 'dark' });
61 await context.addInitScript(value => {
62 localStorage.setItem('theme-preference', value);
63 }, theme);
64 const page = await context.newPage();
65 const errors = [];
66 page.on('pageerror', error => errors.push(`pageerror: ${error.message}`));
67 page.on('console', message => {
68 if (message.type() === 'error') errors.push(`console: ${message.text()}`);
69 });
70 page.on('requestfailed', request => {
71 errors.push(`requestfailed: ${request.url()} ${request.failure()?.errorText || ''}`);
72 });
73 page.on('response', response => {
74 if (response.status() >= 400) {
75 errors.push(`response: ${response.status()} ${response.url()}`);
76 }
77 });
78
79 await page.goto(baseUrl, { waitUntil: 'networkidle' });
80 await page.waitForFunction(() => {
81 const canvas = document.querySelector('#background');
82 if (!canvas) return false;
83 const data = canvas.getContext('2d').getImageData(
84 0,
85 0,
86 canvas.width,
87 canvas.height,
88 ).data;
89 for (let index = 3; index < data.length; index += 4) {
90 if (data[index] >= 20) return true;
91 }
92 return false;
93 });
94
95 const sample = await page.evaluate(() => {
96 const canvas = document.querySelector('#background');
97 const data = canvas.getContext('2d').getImageData(
98 0,
99 0,
100 canvas.width,
101 canvas.height,
102 ).data;
103 let count = 0;
104 let luminance = 0;
105 for (let index = 0; index < data.length; index += 4) {
106 if (data[index + 3] < 20) continue;
107 count++;
108 luminance +=
109 data[index] * 0.2126 +
110 data[index + 1] * 0.7152 +
111 data[index + 2] * 0.0722;
112 }
113 return {
114 count,
115 luminance: count ? luminance / count : 0,
116 rootClass: document.documentElement.className,
117 };
118 });
119
120 if (errors.length) throw new Error(`${theme}\n${errors.join('\n')}`);
121 await context.close();
122 return sample;
123 }
124
125 function findFreePort() {
126 return new Promise((resolve, reject) => {
127 const socket = net.createServer();
128 socket.once('error', reject);
129 socket.listen(0, '127.0.0.1', () => {
130 const address = socket.address();
131 socket.close(error => {
132 if (error) reject(error);
133 else resolve(String(address.port));
134 });
135 });
136 });
137 }
138
139 (async () => {
140 assert.ok(RUNFILES);
141 assert.ok(WORKSPACE);
142 const runfilesWorkspace = path.join(RUNFILES, WORKSPACE);
143 const serverBinary = path.join(
144 runfilesWorkspace,
145 'mrjunejune/mrjunejune_server',
146 );
147 const siteRoot = path.join(runfilesWorkspace, 'mrjunejune/src');
148 const chromiumPath = path.resolve(process.env.CHROMIUM_PATH);
149 const serverLogs = [];
150 let server;
151 let browser;
152
153 try {
154 port = await findFreePort();
155 baseUrl = `http://127.0.0.1:${port}`;
156 const pngFiles = listFiles(siteRoot).filter(file => file.endsWith('.png'));
157 assert.deepEqual(pngFiles, [], `PNG files leaked into runfiles: ${pngFiles}`);
158
159 server = spawn(serverBinary, [], {
160 cwd: runfilesWorkspace,
161 env: { ...process.env, MRJUNEJUNE_PORT: port },
162 stdio: ['ignore', 'pipe', 'pipe'],
163 });
164 server.stdout.on('data', chunk => serverLogs.push(chunk.toString()));
165 server.stderr.on('data', chunk => serverLogs.push(chunk.toString()));
166 await waitForServer(server, serverLogs);
167
168 const home = await (await fetch(baseUrl)).text();
169 const dogGame = await (
170 await fetch(`${baseUrl}/public/dog-game.js`)
171 ).text();
172 const manifest = await (
173 await fetch(`${baseUrl}/public/manifest.json`)
174 ).text();
175 for (const source of [home, dogGame, manifest]) {
176 assert.doesNotMatch(source, /\.png(?:["')]|$)/i);
177 }
178
179 for (const asset of [
180 'sprite_shiba0.webp',
181 'dog-treat.webp',
182 'start-large.webp',
183 'icon-192.webp',
184 'vscode.webp',
185 ]) {
186 const response = await fetch(`${baseUrl}/public/${asset}`);
187 assert.equal(response.status, 200, asset);
188 assert.ok((await response.arrayBuffer()).byteLength > 0, asset);
189 }
190
191 browser = await chromium.launch({
192 executablePath: chromiumPath,
193 headless: true,
194 args: ['--no-sandbox'],
195 });
196 const light = await sampleTheme(browser, 'light');
197 const dark = await sampleTheme(browser, 'dark');
198 assert.ok(light.count > 0);
199 assert.ok(dark.count > 0);
200 assert.ok(light.luminance < 175, JSON.stringify(light));
201 assert.ok(dark.luminance > 200, JSON.stringify(dark));
202 } finally {
203 if (browser) await browser.close();
204 await stopProcess(server);
205 }
206 })().catch(error => {
207 console.error(error.stack || error);
208 process.exitCode = 1;
209 });