Skip to content

Commit 1827e49

Browse files
matmenclaude
andcommitted
Test edge runtimes and Chromium in CI
tests/edge runs every codec and the renderers in Cloudflare's workerd (Miniflare, with and without nodejs_compat) and Vercel's Edge Runtime, bundled by esbuild like deployments. tests/browser bundles them with Vite (which has to emit the worker) and runs them in Chromium through Playwright: with cross-origin isolation threaded AVIF must start workers and write the same file, without it no workers. New CI jobs edge and browser. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lfv41PqHEGbU7SLg5onpG1
1 parent d0642f4 commit 1827e49

9 files changed

Lines changed: 215 additions & 3 deletions

File tree

‎.github/workflows/node.yml‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: node
22

3-
# tests every pushed branch in Node, Deno and Bun, and pull requests from forks (pull requests from branches of this repository are
3+
# tests every pushed branch in Node, Deno, Bun, edge runtimes and Chromium, and pull requests from forks (pull requests from branches of this repository are
44
# already tested by their push); release.yml runs it before publishing
55
on:
66
push:
@@ -76,3 +76,32 @@ jobs:
7676
# (JSR type checks the sources with Deno's TypeScript, stricter than ours: what release.yml would publish)
7777
- if: matrix.runtime == 'deno' && matrix.os == 'ubuntu-latest'
7878
run: deno publish --dry-run --allow-dirty
79+
80+
# edge runtimes (Cloudflare's workerd, with and without Node.js compatibility, and Vercel's Edge Runtime) and Chromium
81+
# (bundled by Vite, threads with cross-origin isolation, without them otherwise), bundled like deployments
82+
edge:
83+
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository
84+
runs-on: ubuntu-latest
85+
steps:
86+
- uses: actions/checkout@v7
87+
- uses: actions/setup-node@v7
88+
with:
89+
node-version: 24.x
90+
- run: npm install
91+
- run: npm run build
92+
- run: npm install --no-save esbuild@0.28.2 miniflare@4.20260730.0 edge-runtime@4.0.1
93+
- run: node tests/edge/run.mjs
94+
95+
browser:
96+
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository
97+
runs-on: ubuntu-latest
98+
steps:
99+
- uses: actions/checkout@v7
100+
- uses: actions/setup-node@v7
101+
with:
102+
node-version: 24.x
103+
- run: npm install
104+
- run: npm run build
105+
- run: npm install --no-save vite@6.4.5 playwright@1.64.1 esbuild@0.28.2
106+
- run: npx playwright install --with-deps chromium
107+
- run: node tests/browser/run.mjs

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@
1313
640x640 pixels at effort 0, 448x448 at 1, 320x320 at 2), browser pages without cross-origin isolation (no
1414
SharedArrayBuffer), runtimes without workers, and workers that fail to start encode on the calling thread as before.
1515

16+
### Tests
17+
18+
- Edge runtimes are tested: Cloudflare's workerd (with and without `nodejs_compat`) and Vercel's Edge Runtime run every
19+
codec and the renderers bundled like deployments (`tests/edge`), and Chromium runs them bundled by Vite, with threads
20+
on a cross-origin isolated page and without on another (`tests/browser`).
21+
1622
## 3.9.0
1723

1824
### Features

‎README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
---
99

1010
**ImageScript** decodes, edits and encodes images in pure TypeScript: no WebAssembly, no native binaries, no
11-
dependencies. It runs in Node.js 22.13+, Deno, Bun and browsers, with `import` or `require()`, and includes type
12-
definitions.
11+
dependencies. It runs in Node.js 22.13+, Deno, Bun, browsers and edge runtimes (Cloudflare Workers, Vercel's Edge
12+
Runtime, Deno Deploy), with `import` or `require()`, and includes type definitions. Encoders use worker threads where
13+
the runtime has them and images are large enough to gain from them.
1314

1415
```sh
1516
npm install imagescript # Node.js, Bun and bundlers

‎tests/browser/page.mjs‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// the browser test's page script: AVIF with and without threads (the same file), the codecs' smoke test
2+
import { Image } from 'imagescript';
3+
import { smoke } from '../edge/smoke.mjs';
4+
5+
const digest = async data => [...new Uint8Array(await crypto.subtle.digest('SHA-256', data))].map(b => b.toString(16).padStart(2, '0')).join('');
6+
(async () => {
7+
const results = { isolated: self.crossOriginIsolated };
8+
try {
9+
const image = new Image(640, 480);
10+
for (let y = 0; y < 480; y++) for (let x = 0; x < 640; x++) image.setPixelAt(x, y, Image.rgbaToColor(x * 3 & 255, y * 5 & 255, (x ^ y) & 255, 255));
11+
results.single = await digest(await image.encodeAVIF({ effort: 1, threads: 1, metadata: { creationTime: 0 } }));
12+
results.threaded = await digest(await image.encodeAVIF({ effort: 1, threads: 4, metadata: { creationTime: 0 } }));
13+
results.smoke = await smoke();
14+
} catch (e) {
15+
results.error = String(e && e.stack || e);
16+
}
17+
globalThis.results = results;
18+
})();

‎tests/browser/run.mjs‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// runs tests/browser/page.mjs in Chromium (Playwright), bundled by Vite (which has to find the worker's module), on a
2+
// page with cross-origin isolation (threads: workers must start and write the same file) and one without (no
3+
// SharedArrayBuffer: the same file without threads). Needs the build and
4+
// npm install --no-save vite playwright && npx playwright install chromium
5+
// (CHROMIUM: a Chromium to use instead of Playwright's)
6+
import http from 'node:http';
7+
import fs from 'node:fs';
8+
import path from 'node:path';
9+
import { fileURLToPath } from 'node:url';
10+
import { build } from 'vite';
11+
import { chromium } from 'playwright';
12+
13+
const here = path.dirname(fileURLToPath(import.meta.url)), out = path.join(here, 'dist');
14+
fs.writeFileSync(path.join(here, 'index.html'), '<!doctype html><script type="module" src="./page.mjs"></script>\n');
15+
try {
16+
await build({
17+
root: here, logLevel: 'error', build: { outDir: out, emptyOutDir: true, target: 'es2022', chunkSizeWarningLimit: 1e9, assetsInlineLimit: 0 },
18+
resolve: { alias: { imagescript: path.join(here, '../../dist/index.js') } },
19+
assetsInclude: ['**/*.ttf'],
20+
plugins: [{ name: 'binary', enforce: 'pre', load: id => /\.(png|ttf)$/.test(id) ? `export default Uint8Array.from(atob('${fs.readFileSync(id).toString('base64')}'), c => c.charCodeAt(0));` : null }],
21+
});
22+
} finally {
23+
fs.rmSync(path.join(here, 'index.html'));
24+
}
25+
if (!fs.readdirSync(path.join(out, 'assets')).some(f => f.startsWith('worker'))) throw new Error('Vite did not bundle the worker');
26+
27+
let failed = false;
28+
const browser = await chromium.launch(process.env.CHROMIUM ? { executablePath: process.env.CHROMIUM } : {});
29+
try {
30+
for (const isolated of [true, false]) {
31+
const server = http.createServer((request, response) => {
32+
const file = path.join(out, request.url === '/' ? 'index.html' : decodeURIComponent(request.url));
33+
if (!file.startsWith(out) || !fs.existsSync(file)) return response.writeHead(404).end();
34+
response.writeHead(200, {
35+
'Content-Type': file.endsWith('.js') ? 'text/javascript' : 'text/html',
36+
...isolated && { 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Embedder-Policy': 'require-corp' },
37+
});
38+
fs.createReadStream(file).pipe(response);
39+
}).listen(0);
40+
const page = await browser.newPage();
41+
let workers = 0;
42+
page.on('worker', () => workers++);
43+
page.on('pageerror', e => console.log('page error:', e.message));
44+
await page.goto(`http://localhost:${server.address().port}/`);
45+
await page.waitForFunction(() => globalThis.results, null, { timeout: 120000 });
46+
const r = await page.evaluate(() => globalThis.results);
47+
await page.close();
48+
server.close();
49+
const problems = [];
50+
if (r.error) problems.push(r.error);
51+
if (r.isolated !== isolated) problems.push(`crossOriginIsolated ${r.isolated}`);
52+
if (r.single !== r.threaded) problems.push('threads wrote a different file');
53+
if (isolated ? workers < 2 : workers) problems.push(`${workers} workers started`);
54+
for (const [step, result] of Object.entries(r.smoke ?? {})) if (String(result).startsWith('FAIL')) problems.push(`${step}: ${result}`);
55+
console.log(`${isolated ? 'cross-origin isolated' : 'not isolated'}: ${workers} workers, ${problems.length ? problems.join('\n ') : 'ok'}`);
56+
if (problems.length) failed = true;
57+
}
58+
} finally {
59+
await browser.close();
60+
fs.rmSync(out, { recursive: true, force: true });
61+
}
62+
if (failed) process.exit(1);
63+
console.log('browser passed');

‎tests/edge/edge-runtime.mjs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// Vercel's Edge Runtime running the smoke test (a script handling fetch events)
2+
import { smoke } from './smoke.mjs';
3+
4+
addEventListener('fetch', event => event.respondWith(smoke().then(results => Response.json(results))));

‎tests/edge/run.mjs‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// runs the smoke test (smoke.mjs) in edge runtimes, bundled like their deployments are: Cloudflare's workerd (through
2+
// Miniflare, with and without Node.js compatibility) and Vercel's Edge Runtime. Needs the build and
3+
// npm install --no-save esbuild miniflare edge-runtime
4+
import { build } from 'esbuild';
5+
import { Miniflare } from 'miniflare';
6+
import { EdgeRuntime } from 'edge-runtime';
7+
8+
const bundle = async (entry, format) => (await build({
9+
entryPoints: [new URL(entry, import.meta.url).pathname], bundle: true, write: false, format, target: 'es2022', platform: 'neutral',
10+
loader: { '.png': 'binary', '.ttf': 'binary' }, logLevel: 'error',
11+
})).outputFiles[0].text;
12+
13+
let failed = false;
14+
const report = (name, results) => {
15+
console.log(name);
16+
for (const [step, result] of Object.entries(results)) {
17+
console.log(` ${step}: ${result}`);
18+
if (result.startsWith('FAIL')) failed = true;
19+
}
20+
};
21+
22+
const worker = await bundle('./workerd.mjs', 'esm');
23+
for (const flags of [[], ['nodejs_compat']]) {
24+
const mf = new Miniflare({ modules: true, script: worker, compatibilityDate: '2026-08-01', compatibilityFlags: flags });
25+
try {
26+
const response = await mf.dispatchFetch('http://localhost/');
27+
report(`workerd${flags.length ? ` (${flags.join(', ')})` : ''}`, await response.json());
28+
} finally {
29+
await mf.dispose();
30+
}
31+
}
32+
33+
const runtime = new EdgeRuntime({ initialCode: await bundle('./edge-runtime.mjs', 'iife') });
34+
report('edge-runtime', await (await runtime.dispatchFetch('http://localhost/')).json());
35+
36+
if (failed) process.exit(1);
37+
console.log('edge runtimes passed');

‎tests/edge/smoke.mjs‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// the codecs and renderers once each, for runtimes without file access: the fixtures are bundled (see run.mjs)
2+
import { Image, Animation, Frame } from '../../dist/index.js';
3+
import png from '../targets/external.png';
4+
import font from '../fonts/opensans bold.ttf';
5+
6+
export async function smoke() {
7+
const results = {};
8+
const step = async (name, test) => {
9+
try {
10+
results[name] = await test();
11+
} catch (e) {
12+
results[name] = `FAIL ${e && e.stack || e}`;
13+
}
14+
};
15+
const photo = (await Image.decode(png)).resize(256, Image.RESIZE_AUTO), alpha = photo.clone();
16+
for (let i = 3; i < alpha.bitmap.length; i += 4) alpha.bitmap[i] = (i >> 2) % 256;
17+
// (decoded back, the same size)
18+
const back = async data => {
19+
const image = await Image.decode(data);
20+
if (image.width !== photo.width || image.height !== photo.height) throw new Error(`decoded as ${image.width}x${image.height}`);
21+
return `${data.length} B`;
22+
};
23+
await step('png', async () => back(await alpha.encode()));
24+
await step('jpeg', async () => back(await photo.encodeJPEG(80)));
25+
await step('webp lossless', async () => back(await alpha.encodeWebP()));
26+
await step('webp lossy', async () => back(await alpha.encodeWebP({ quality: 75 })));
27+
await step('avif', async () => back(await alpha.encodeAVIF({ effort: 0 })));
28+
await step('jxl lossless', async () => back(await alpha.encodeJXL()));
29+
await step('jxl lossy', async () => back(await alpha.encodeJXL({ quality: 85, effort: 2 })));
30+
await step('tga, bmp, qoi, pnm', async () => (await Promise.all([photo.encodeTGA(), photo.encodeBMP(), photo.encodeQOI(), photo.encodePNM()].map(async d => back(await d)))).join(', '));
31+
const frames = [Frame.from(photo.clone().resize(64, 64), 100), Frame.from(photo.clone().resize(64, 64).invert(), 100)];
32+
for (const [name, encode] of [['gif', a => a.encodeGIF()], ['apng', a => a.encodeAPNG()], ['animated webp', a => a.encodeWebP({ quality: 70 })]]) {
33+
await step(name, async () => {
34+
const decoded = await Animation.decode(await encode(new Animation(frames)));
35+
if (decoded.length !== 2) throw new Error(`${decoded.length} frames`);
36+
return '2 frames';
37+
});
38+
}
39+
await step('text', async () => {
40+
const text = await Image.renderText(font, 24, 'Edge äöü fi', 0x000000ff);
41+
return `${text.width}x${text.height}`;
42+
});
43+
await step('svg', async () => {
44+
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="80"><linearGradient id="g"><stop offset="0" stop-color="red"/><stop offset="1" stop-color="blue"/></linearGradient>'
45+
+ '<rect width="200" height="80" fill="url(#g)" filter="drop-shadow(2px 2px 2px black)"/><text x="10" y="50" font-size="30" fill="white">Hallo</text></svg>';
46+
const image = await Image.renderSVG(svg, { fonts: [font] });
47+
return `${image.width}x${image.height}`;
48+
});
49+
return results;
50+
}

‎tests/edge/workerd.mjs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// a Cloudflare Worker running the smoke test
2+
import { smoke } from './smoke.mjs';
3+
4+
export default { fetch: async () => Response.json(await smoke()) };

0 commit comments

Comments
 (0)