Skip to content

Commit 7e5f896

Browse files
claudekalwalt
authored andcommitted
test: add Vitest unit tests and Playwright e2e smoke test
Two complementary layers: Vitest covers the pure JS paths that don't need wasm: - Utils.string2Uint8Data, fetchRemoteData, fetchRemoteDataBlob (fetch is stubbed via vi.fn so no network is touched) - ARFset constructor (option handling, defaults, initial fields, version banner) with the wasm wrapper mocked out so importing src/ARFset.js stays cheap Playwright drives a real Chromium against example_es6.html, waits for the canvas to be populated by display(), and asserts the canvas has non-empty pixel content. This exercises the full wasm -> JS -> canvas path end to end. Scripts: npm test -> vitest run npm run test:watch -> vitest in watch mode npm run test:e2e:install -> playwright install --with-deps chromium (one-shot per machine) npm run test:e2e -> playwright test devDependencies added: vitest, @playwright/test, http-server (needed for Playwright's webServer block). Refs #28 Refs #31
1 parent ecd38b6 commit 7e5f896

5 files changed

Lines changed: 203 additions & 2 deletions

File tree

package.json

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,21 @@
3131
"url": "https://github.com/webarkit/FeatureSET-Display/issues"
3232
},
3333
"devDependencies": {
34-
"vite": "^8.0.16"
34+
"@playwright/test": "^1.50.0",
35+
"http-server": "^14.1.1",
36+
"vite": "^8.0.16",
37+
"vitest": "^2.1.0"
3538
},
3639
"scripts": {
3740
"build": "node tools/makem.cjs",
3841
"build-no-libar": "node tools/makem.cjs --no-libar",
3942
"dev-es6": "vite build --watch",
4043
"build-es6": "vite build",
41-
"serve": "npx http-server -c -1"
44+
"serve": "npx http-server -c -1",
45+
"test": "vitest run",
46+
"test:watch": "vitest",
47+
"test:e2e": "playwright test",
48+
"test:e2e:install": "playwright install --with-deps chromium"
4249
},
4350
"license": "LGPL-3.0"
4451
}

playwright.config.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { defineConfig, devices } from '@playwright/test';
2+
3+
// Runs against the built artifacts in dist/ and build/. CI sequences
4+
// `npm run build && npm run build-es6 && npm run test:e2e`. Locally
5+
// the same applies.
6+
export default defineConfig({
7+
testDir: 'tests/e2e',
8+
fullyParallel: false,
9+
reporter: 'list',
10+
use: {
11+
baseURL: 'http://127.0.0.1:8765',
12+
trace: 'retain-on-failure',
13+
},
14+
webServer: {
15+
command: 'npx http-server -c-1 -p 8765 --silent',
16+
url: 'http://127.0.0.1:8765',
17+
reuseExistingServer: !process.env.CI,
18+
timeout: 30_000,
19+
},
20+
projects: [
21+
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
22+
],
23+
});

tests/e2e/marker-load.spec.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
// End-to-end smoke test: load example_es6.html, wait for the wasm
4+
// runtime to fetch + decode the pinball marker, then verify the
5+
// canvas has actually been painted (non-empty pixel data).
6+
test('example_es6.html loads the pinball marker and renders the canvas', async ({ page }) => {
7+
const consoleErrors = [];
8+
page.on('pageerror', (err) => consoleErrors.push(err.message));
9+
page.on('console', (msg) => {
10+
if (msg.type() === 'error') consoleErrors.push(msg.text());
11+
});
12+
13+
await page.goto('/example/example_es6.html');
14+
15+
// ARFset.display() inserts <canvas id="iSet"> and dispatches 'imageEv'
16+
// once the marker has been drawn. Wait for the canvas to appear and
17+
// for the event to fire.
18+
const canvas = page.locator('#iSet');
19+
await canvas.waitFor({ state: 'attached', timeout: 30_000 });
20+
21+
await page.waitForFunction(() => {
22+
return new Promise((resolve) => {
23+
// imageEv may have already fired between locator wait and here.
24+
const c = document.getElementById('iSet');
25+
if (c && c.width > 0 && c.height > 0) return resolve(true);
26+
document.addEventListener('imageEv', () => resolve(true), { once: true });
27+
});
28+
}, undefined, { timeout: 30_000 });
29+
30+
const dimensions = await canvas.evaluate((el) => ({ w: el.width, h: el.height }));
31+
expect(dimensions.w).toBeGreaterThan(0);
32+
expect(dimensions.h).toBeGreaterThan(0);
33+
34+
// Sample a few pixels: at least one must be non-black, confirming the
35+
// marker image was actually painted (not just a blank canvas).
36+
const hasContent = await canvas.evaluate((el) => {
37+
const ctx = el.getContext('2d');
38+
const data = ctx.getImageData(0, 0, el.width, el.height).data;
39+
// step coarsely to keep this fast for big markers
40+
for (let i = 0; i < data.length; i += 4 * 100) {
41+
if (data[i] > 0 || data[i + 1] > 0 || data[i + 2] > 0) return true;
42+
}
43+
return false;
44+
});
45+
expect(hasContent).toBe(true);
46+
47+
expect(consoleErrors, `unexpected errors: ${consoleErrors.join(' | ')}`).toEqual([]);
48+
});

tests/unit/arfset.test.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
3+
// Stub the emscripten wasm wrapper so importing ARFset doesn't try to
4+
// instantiate wasm in Node. The factory is only invoked from
5+
// ARFset.initialize(), which these tests deliberately avoid calling.
6+
vi.mock('../../build/arfset_ES6_wasm.js', () => ({
7+
default: vi.fn(),
8+
}));
9+
10+
const { default: ARFset } = await import('../../src/ARFset.js');
11+
12+
describe('ARFset constructor', () => {
13+
beforeEach(() => {
14+
// Quiet the version banner the constructor logs.
15+
vi.spyOn(console, 'log').mockImplementation(() => {});
16+
});
17+
18+
it('uses the documented defaults when no options are passed', () => {
19+
const ar = new ARFset();
20+
expect(ar.width).toBe(893);
21+
expect(ar.height).toBe(1117);
22+
});
23+
24+
it('applies width and height from options', () => {
25+
const ar = new ARFset({ width: 640, height: 480 });
26+
expect(ar.width).toBe(640);
27+
expect(ar.height).toBe(480);
28+
});
29+
30+
it('uses defaults for any option that is omitted', () => {
31+
expect(new ARFset({ width: 100 }).height).toBe(1117);
32+
expect(new ARFset({ height: 100 }).width).toBe(893);
33+
});
34+
35+
it('initialises bookkeeping counters to zero', () => {
36+
const ar = new ARFset();
37+
expect(ar.id).toBe(0);
38+
expect(ar.nftMarkerCount).toBe(0);
39+
expect(ar.numIset).toBe(0);
40+
});
41+
42+
it('exposes the library version string', () => {
43+
expect(new ARFset().version).toMatch(/^\d+\.\d+\.\d+$/);
44+
});
45+
});

tests/unit/utils.test.js

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2+
import Utils from '../../src/Utils.js';
3+
4+
describe('Utils.string2Uint8Data', () => {
5+
it('encodes ASCII chars as their code points', () => {
6+
const out = Utils.string2Uint8Data('abc');
7+
expect(out).toBeInstanceOf(Uint8Array);
8+
expect(Array.from(out)).toEqual([0x61, 0x62, 0x63]);
9+
});
10+
11+
it('truncates non-ASCII chars to their low byte', () => {
12+
// '€' is U+20AC; low byte is 0xAC.
13+
const out = Utils.string2Uint8Data('a€');
14+
expect(out.length).toBe(2);
15+
expect(out[1]).toBe(0xAC);
16+
});
17+
18+
it('returns an empty array for the empty string', () => {
19+
expect(Utils.string2Uint8Data('').length).toBe(0);
20+
});
21+
});
22+
23+
describe('Utils.fetchRemoteData', () => {
24+
let fetchMock;
25+
26+
beforeEach(() => {
27+
fetchMock = vi.fn();
28+
globalThis.fetch = fetchMock;
29+
});
30+
31+
afterEach(() => {
32+
delete globalThis.fetch;
33+
});
34+
35+
it('returns a Uint8Array for a 200 response', async () => {
36+
fetchMock.mockResolvedValue({
37+
ok: true,
38+
arrayBuffer: () => Promise.resolve(new Uint8Array([1, 2, 3]).buffer),
39+
});
40+
const out = await Utils.fetchRemoteData('https://example/test');
41+
expect(out).toBeInstanceOf(Uint8Array);
42+
expect(Array.from(out)).toEqual([1, 2, 3]);
43+
expect(fetchMock).toHaveBeenCalledWith('https://example/test');
44+
});
45+
46+
it('throws on non-ok response with status info', async () => {
47+
fetchMock.mockResolvedValue({
48+
ok: false,
49+
status: 404,
50+
statusText: 'Not Found',
51+
});
52+
await expect(Utils.fetchRemoteData('https://example/missing')).rejects.toThrow(/404/);
53+
});
54+
});
55+
56+
describe('Utils.fetchRemoteDataBlob', () => {
57+
it('treats a multi-line string as inline data, not a URL', async () => {
58+
const fetchSpy = vi.fn();
59+
globalThis.fetch = fetchSpy;
60+
const out = await Utils.fetchRemoteDataBlob('line one\nline two');
61+
expect(out).toBeInstanceOf(Uint8Array);
62+
expect(out.length).toBe('line one\nline two'.length);
63+
expect(fetchSpy).not.toHaveBeenCalled();
64+
delete globalThis.fetch;
65+
});
66+
67+
it('falls through to fetch for a single-line URL', async () => {
68+
const fetchSpy = vi.fn().mockResolvedValue({
69+
ok: true,
70+
arrayBuffer: () => Promise.resolve(new Uint8Array([9]).buffer),
71+
});
72+
globalThis.fetch = fetchSpy;
73+
const out = await Utils.fetchRemoteDataBlob('https://example/blob');
74+
expect(out[0]).toBe(9);
75+
expect(fetchSpy).toHaveBeenCalledWith('https://example/blob');
76+
delete globalThis.fetch;
77+
});
78+
});

0 commit comments

Comments
 (0)