wgsl-play 0.0.39 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -2
- package/dist/{WgslPlay-CSjRo-5Z.d.ts → WgslPlay-ouKx64GN.d.ts} +52 -3
- package/dist/WgslPlay-zK9mE5rV.js +1544 -0
- package/dist/WgslPlay.d.ts +2 -2
- package/dist/WgslPlay.js +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/jsx-preact.d.ts +12 -0
- package/dist/jsx-preact.js +0 -0
- package/dist/wgsl-play.js +1834 -754
- package/package.json +12 -8
- package/src/CanvasSize.ts +27 -0
- package/src/ComputeBuild.ts +151 -0
- package/src/FragmentRender.ts +147 -0
- package/src/PlaybackControls.ts +36 -13
- package/src/RenderResources.ts +132 -0
- package/src/Renderer.ts +112 -138
- package/src/ResultsPanel.ts +261 -0
- package/src/WgslPlay.css +80 -8
- package/src/WgslPlay.ts +346 -103
- package/src/icons/refresh.svg +1 -0
- package/src/jsx-preact.ts +15 -0
- package/src/test/BundleHydrator.test.ts +8 -11
- package/src/test/CanvasSize.test.ts +48 -0
- package/src/test/E2eHelpers.ts +117 -0
- package/src/test/RenderFrame.e2e.ts +33 -0
- package/src/test/ResultsPanel.test.ts +164 -0
- package/src/test/WgslPlay.basics.e2e.ts +87 -0
- package/src/test/WgslPlay.compute.e2e.ts +147 -0
- package/src/test/WgslPlay.connect.e2e.ts +101 -0
- package/src/test/WgslPlay.loading.e2e.ts +68 -0
- package/src/test/WgslPlay.resize.e2e.ts +199 -0
- package/src/test/WgslPlay.resources.e2e.ts +67 -0
- package/src/test/WgslPlay.resources.e2e.ts-snapshots/buffer-zero-init-chromium-darwin.png +0 -0
- package/src/test/WgslPlay.resources.e2e.ts-snapshots/texture-grid-chromium-darwin.png +0 -0
- package/src/test/WgslPlay.resources.e2e.ts-snapshots/texture-img-green-chromium-darwin.png +0 -0
- package/src/test/WgslPlay.resources.e2e.ts-snapshots/texture-img-magenta-chromium-darwin.png +0 -0
- package/src/test/WgslPlay.resources.e2e.ts-snapshots/texture-multi-chromium-darwin.png +0 -0
- package/src/test/WgslPlay.uniforms.e2e.ts +57 -0
- package/dist/WgslPlay-LsU6XE09.js +0 -933
- package/src/test/WgslPlay.e2e.ts +0 -327
- /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.basics.e2e.ts-snapshots}/basic-shader-chromium-darwin.png +0 -0
- /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.basics.e2e.ts-snapshots}/conditions-after-red-chromium-darwin.png +0 -0
- /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.basics.e2e.ts-snapshots}/conditions-initial-green-chromium-darwin.png +0 -0
- /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.connect.e2e.ts-snapshots}/connect-conditions-green-chromium-darwin.png +0 -0
- /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.connect.e2e.ts-snapshots}/connect-conditions-red-chromium-darwin.png +0 -0
- /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.connect.e2e.ts-snapshots}/connect-dynamic-npm-chromium-darwin.png +0 -0
- /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.connect.e2e.ts-snapshots}/connect-source-external-chromium-darwin.png +0 -0
- /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.connect.e2e.ts-snapshots}/connect-source-multifile-chromium-darwin.png +0 -0
- /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.loading.e2e.ts-snapshots}/link-import-chromium-darwin.png +0 -0
- /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.loading.e2e.ts-snapshots}/npm-cdn-chromium-darwin.png +0 -0
- /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.loading.e2e.ts-snapshots}/shader-root-internal-chromium-darwin.png +0 -0
- /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.loading.e2e.ts-snapshots}/shader-root-src-chromium-darwin.png +0 -0
- /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.loading.e2e.ts-snapshots}/static-import-chromium-darwin.png +0 -0
- /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.uniforms.e2e.ts-snapshots}/uniforms-initial-chromium-darwin.png +0 -0
- /package/src/test/{WgslPlay.e2e.ts-snapshots → WgslPlay.uniforms.e2e.ts-snapshots}/uniforms-slider-changed-chromium-darwin.png +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" d="M17.66 17.66A8 8 0 1 1 19.45 9M19.95 4v5h-5"/></svg>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Preact JSX augmentation for `<wgsl-play>`.
|
|
2
|
+
* Side-effect import: `import "wgsl-play/jsx-preact"` once anywhere in your TS source. */
|
|
3
|
+
|
|
4
|
+
import type { HTMLAttributes } from "preact";
|
|
5
|
+
import type { WgslPlay, WgslPlayAttrs } from "./WgslPlay.ts";
|
|
6
|
+
|
|
7
|
+
type WgslPlayTag = HTMLAttributes<WgslPlay> & WgslPlayAttrs;
|
|
8
|
+
|
|
9
|
+
declare module "preact" {
|
|
10
|
+
namespace JSX {
|
|
11
|
+
interface IntrinsicElements {
|
|
12
|
+
"wgsl-play": WgslPlayTag;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import {
|
|
5
|
-
import { parseTar } from "nanotar";
|
|
4
|
+
import { parseTarGzip } from "nanotar";
|
|
6
5
|
import { expect, test } from "vitest";
|
|
7
6
|
import type { WeslBundleFile } from "wesl-fetch";
|
|
8
7
|
import { loadBundlesFromFiles } from "wesl-fetch";
|
|
@@ -11,10 +10,9 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
11
10
|
const __dirname = dirname(__filename);
|
|
12
11
|
const testPkgDir = join(__dirname, "../../../test_pkg");
|
|
13
12
|
|
|
14
|
-
function loadBundlesFromTgz(tgzPath: string, packageName: string) {
|
|
13
|
+
async function loadBundlesFromTgz(tgzPath: string, packageName: string) {
|
|
15
14
|
const gzipData = readFileSync(tgzPath);
|
|
16
|
-
const
|
|
17
|
-
const entries = parseTar(tarData);
|
|
15
|
+
const entries = await parseTarGzip(new Uint8Array(gzipData));
|
|
18
16
|
const bundleFiles: WeslBundleFile[] = entries
|
|
19
17
|
.filter(f => f.name.endsWith("weslBundle.js"))
|
|
20
18
|
.map(f => ({ packagePath: f.name, content: f.text, packageName }));
|
|
@@ -39,8 +37,8 @@ test("load bundles with dependencies from tgz", async () => {
|
|
|
39
37
|
const multiTgz = join(testPkgDir, "multi_pkg-0.0.1.tgz");
|
|
40
38
|
const depTgz = join(testPkgDir, "dependent_package-0.0.1.tgz");
|
|
41
39
|
|
|
42
|
-
const multiFiles = extractBundleFiles(multiTgz, "multi_pkg");
|
|
43
|
-
const depFiles = extractBundleFiles(depTgz, "dependent_package");
|
|
40
|
+
const multiFiles = await extractBundleFiles(multiTgz, "multi_pkg");
|
|
41
|
+
const depFiles = await extractBundleFiles(depTgz, "dependent_package");
|
|
44
42
|
|
|
45
43
|
// Load all bundles together - each file knows its package name
|
|
46
44
|
const allFiles = [...depFiles, ...multiFiles];
|
|
@@ -109,13 +107,12 @@ test("circular dependencies", async () => {
|
|
|
109
107
|
expect(b.dependencies?.[0]).toBe(a);
|
|
110
108
|
});
|
|
111
109
|
|
|
112
|
-
function extractBundleFiles(
|
|
110
|
+
async function extractBundleFiles(
|
|
113
111
|
tgzPath: string,
|
|
114
112
|
packageName: string,
|
|
115
|
-
): WeslBundleFile[] {
|
|
113
|
+
): Promise<WeslBundleFile[]> {
|
|
116
114
|
const gzipData = readFileSync(tgzPath);
|
|
117
|
-
const
|
|
118
|
-
const entries = parseTar(tarData);
|
|
115
|
+
const entries = await parseTarGzip(new Uint8Array(gzipData));
|
|
119
116
|
return entries
|
|
120
117
|
.filter(f => f.name.endsWith("weslBundle.js"))
|
|
121
118
|
.map(f => ({ packagePath: f.name, content: f.text, packageName }));
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
import { clampCanvas, entrySize } from "../CanvasSize.ts";
|
|
3
|
+
|
|
4
|
+
function fakeEntry(opts: {
|
|
5
|
+
dp?: { inlineSize: number; blockSize: number };
|
|
6
|
+
css?: { inlineSize: number; blockSize: number };
|
|
7
|
+
rect?: { width: number; height: number };
|
|
8
|
+
}): ResizeObserverEntry {
|
|
9
|
+
return {
|
|
10
|
+
devicePixelContentBoxSize: opts.dp ? [opts.dp] : undefined,
|
|
11
|
+
contentBoxSize: opts.css ? [opts.css] : undefined,
|
|
12
|
+
contentRect: opts.rect ?? { width: 0, height: 0 },
|
|
13
|
+
} as unknown as ResizeObserverEntry;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test("entrySize prefers devicePixelContentBoxSize when no override", () => {
|
|
17
|
+
const entry = fakeEntry({
|
|
18
|
+
dp: { inlineSize: 800, blockSize: 600 },
|
|
19
|
+
css: { inlineSize: 400, blockSize: 300 },
|
|
20
|
+
});
|
|
21
|
+
expect(entrySize(entry, null)).toEqual([800, 600]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("entrySize falls back to contentBoxSize × devicePixelRatio on Safari", () => {
|
|
25
|
+
globalThis.devicePixelRatio = 2;
|
|
26
|
+
const entry = fakeEntry({ css: { inlineSize: 400, blockSize: 300 } });
|
|
27
|
+
expect(entrySize(entry, null)).toEqual([800, 600]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("entrySize respects pixel-ratio override even when devicePixelContentBoxSize present", () => {
|
|
31
|
+
const entry = fakeEntry({
|
|
32
|
+
dp: { inlineSize: 800, blockSize: 600 },
|
|
33
|
+
css: { inlineSize: 400, blockSize: 300 },
|
|
34
|
+
});
|
|
35
|
+
expect(entrySize(entry, "1")).toEqual([400, 300]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("entrySize falls back to contentRect when contentBoxSize missing", () => {
|
|
39
|
+
const entry = fakeEntry({ rect: { width: 400, height: 300 } });
|
|
40
|
+
expect(entrySize(entry, "2")).toEqual([800, 600]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("clampCanvas floors and clamps to [1, max]", () => {
|
|
44
|
+
expect(clampCanvas(799.9, 8192)).toBe(799);
|
|
45
|
+
expect(clampCanvas(0.5, 8192)).toBe(1);
|
|
46
|
+
expect(clampCanvas(12000, 8192)).toBe(8192);
|
|
47
|
+
expect(clampCanvas(-5, 8192)).toBe(1);
|
|
48
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/** Shared Playwright helpers for wgsl-play e2e suites. */
|
|
2
|
+
import { expect, type Page } from "@playwright/test";
|
|
3
|
+
|
|
4
|
+
/** Wait for wgsl-play custom element to be defined and upgraded. */
|
|
5
|
+
export async function waitForWgslPlay(page: Page) {
|
|
6
|
+
await page.waitForFunction(() => customElements.get("wgsl-play"));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Wait for a wgsl-play element to render at least one frame. */
|
|
10
|
+
export async function waitForFrame(page: Page, selector: string) {
|
|
11
|
+
await page.waitForFunction(
|
|
12
|
+
sel =>
|
|
13
|
+
(document.querySelector(sel) as HTMLElement & { frameCount: number })
|
|
14
|
+
?.frameCount > 0,
|
|
15
|
+
selector,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Get current frameCount for a wgsl-play element. */
|
|
20
|
+
export async function getFrameCount(
|
|
21
|
+
page: Page,
|
|
22
|
+
selector: string,
|
|
23
|
+
): Promise<number> {
|
|
24
|
+
return page.evaluate(
|
|
25
|
+
sel =>
|
|
26
|
+
(document.querySelector(sel) as HTMLElement & { frameCount: number })
|
|
27
|
+
?.frameCount ?? 0,
|
|
28
|
+
selector,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Wait for frameCount to increase from current value (for re-renders after changes). */
|
|
33
|
+
export async function waitForNewFrame(page: Page, selector: string) {
|
|
34
|
+
const before = await getFrameCount(page, selector);
|
|
35
|
+
await page.waitForFunction(
|
|
36
|
+
([sel, prev]) =>
|
|
37
|
+
((document.querySelector(sel) as HTMLElement & { frameCount: number })
|
|
38
|
+
?.frameCount ?? 0) > prev,
|
|
39
|
+
[selector, before] as const,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Get canvas bounding box from inside shadow DOM. */
|
|
44
|
+
export async function getCanvasBox(page: Page, playerId: string) {
|
|
45
|
+
return page.evaluate(id => {
|
|
46
|
+
const el = document.querySelector(id) as HTMLElement | null;
|
|
47
|
+
const canvas = el?.shadowRoot?.querySelector("canvas");
|
|
48
|
+
if (!canvas) return null;
|
|
49
|
+
const rect = canvas.getBoundingClientRect();
|
|
50
|
+
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
|
51
|
+
}, playerId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Read the structured table data from a player's compute results panel. */
|
|
55
|
+
export async function readResultsPanel(page: Page, playerId: string) {
|
|
56
|
+
const data = await page.evaluate(id => {
|
|
57
|
+
const el = document.querySelector(id) as HTMLElement | null;
|
|
58
|
+
const panel = el?.shadowRoot?.querySelector(".results-panel");
|
|
59
|
+
if (!panel) return null;
|
|
60
|
+
return Array.from(panel.querySelectorAll("section.result")).map(s => ({
|
|
61
|
+
caption: s.querySelector(".result-caption")?.textContent,
|
|
62
|
+
headers: Array.from(s.querySelectorAll("thead th")).map(
|
|
63
|
+
c => c.textContent,
|
|
64
|
+
),
|
|
65
|
+
rows: Array.from(s.querySelectorAll("tbody tr")).map(r =>
|
|
66
|
+
Array.from(r.querySelectorAll("td")).map(c => c.textContent),
|
|
67
|
+
),
|
|
68
|
+
}));
|
|
69
|
+
}, playerId);
|
|
70
|
+
if (!data) throw new Error(`no results panel found for ${playerId}`);
|
|
71
|
+
return data;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Wait for at least one results-panel table to be populated. */
|
|
75
|
+
export async function waitForResults(page: Page, playerId: string) {
|
|
76
|
+
await page.waitForFunction(id => {
|
|
77
|
+
const el = document.querySelector(id) as HTMLElement | null;
|
|
78
|
+
const panel = el?.shadowRoot?.querySelector(".results-panel");
|
|
79
|
+
return (
|
|
80
|
+
!!panel &&
|
|
81
|
+
!panel.hasAttribute("hidden") &&
|
|
82
|
+
panel.querySelector("tbody tr") !== null
|
|
83
|
+
);
|
|
84
|
+
}, playerId);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Snapshot the structured DOM-text data from a results panel. */
|
|
88
|
+
export async function expectResultsPanelSnapshot(
|
|
89
|
+
page: Page,
|
|
90
|
+
playerId: string,
|
|
91
|
+
name: string,
|
|
92
|
+
) {
|
|
93
|
+
const data = await readResultsPanel(page, playerId);
|
|
94
|
+
expect(data).toMatchSnapshot(name);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Scroll player into view and snapshot its shadow DOM canvas. */
|
|
98
|
+
export async function expectCanvasSnapshot(
|
|
99
|
+
page: Page,
|
|
100
|
+
playerId: string,
|
|
101
|
+
name: string,
|
|
102
|
+
) {
|
|
103
|
+
await page.locator(playerId).scrollIntoViewIfNeeded();
|
|
104
|
+
const box = await getCanvasBox(page, playerId);
|
|
105
|
+
if (!box) {
|
|
106
|
+
const debug = await page.evaluate(id => {
|
|
107
|
+
const el = document.querySelector(id);
|
|
108
|
+
return { found: !!el, hasShadow: !!el?.shadowRoot };
|
|
109
|
+
}, playerId);
|
|
110
|
+
throw new Error(
|
|
111
|
+
`Canvas not found for ${playerId}: ${JSON.stringify(debug)}`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
await expect(page).toHaveScreenshot(name, {
|
|
115
|
+
clip: { x: box.x, y: box.y, width: box.width, height: box.height },
|
|
116
|
+
});
|
|
117
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** E2E test for the public `renderFrame()` capture hook. */
|
|
2
|
+
import { expect, test } from "@playwright/test";
|
|
3
|
+
import { waitForFrame, waitForWgslPlay } from "./E2eHelpers.ts";
|
|
4
|
+
|
|
5
|
+
test("renderFrame captures a PNG even when called mid-build", async ({
|
|
6
|
+
page,
|
|
7
|
+
}) => {
|
|
8
|
+
await page.goto("/basics.html");
|
|
9
|
+
await waitForWgslPlay(page);
|
|
10
|
+
await waitForFrame(page, "#basicPlayer");
|
|
11
|
+
|
|
12
|
+
// Replace the shader and call renderFrame() synchronously after, without
|
|
13
|
+
// awaiting any compile-success event. awaitIdleBuild must let the build
|
|
14
|
+
// finish before the snapshot.
|
|
15
|
+
const bytes = await page.evaluate(async () => {
|
|
16
|
+
const el = document.querySelector("#basicPlayer") as HTMLElement & {
|
|
17
|
+
shader: string;
|
|
18
|
+
renderFrame: () => Promise<void>;
|
|
19
|
+
};
|
|
20
|
+
el.shader = `import env::u;
|
|
21
|
+
@fragment fn fs_main(@builtin(position) pos: vec4f) -> @location(0) vec4f {
|
|
22
|
+
return vec4f(1.0, 0.0, 0.0, 1.0);
|
|
23
|
+
}`;
|
|
24
|
+
await el.renderFrame();
|
|
25
|
+
const canvas = el.shadowRoot?.querySelector("canvas") as HTMLCanvasElement;
|
|
26
|
+
const blob = await new Promise<Blob | null>(r =>
|
|
27
|
+
canvas.toBlob(b => r(b), "image/png"),
|
|
28
|
+
);
|
|
29
|
+
if (!blob) throw new Error("toBlob returned null");
|
|
30
|
+
return Array.from(new Uint8Array(await blob.arrayBuffer()).slice(0, 4));
|
|
31
|
+
});
|
|
32
|
+
expect(bytes).toEqual([0x89, 0x50, 0x4e, 0x47]);
|
|
33
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
import { parseSrcModule } from "wesl";
|
|
3
|
+
import { TypeShapeError, varReflection } from "wesl-reflect";
|
|
4
|
+
import { type BufferEntry, tableData } from "../ResultsPanel.ts";
|
|
5
|
+
|
|
6
|
+
function entry(src: string, varName: string, data: ArrayBuffer): BufferEntry {
|
|
7
|
+
const ast = parseSrcModule({
|
|
8
|
+
modulePath: "test",
|
|
9
|
+
debugFilePath: "test.wesl",
|
|
10
|
+
src,
|
|
11
|
+
});
|
|
12
|
+
return { reflection: varReflection(ast, varName), data };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function f32Buffer(values: number[]): ArrayBuffer {
|
|
16
|
+
return new Float32Array(values).buffer;
|
|
17
|
+
}
|
|
18
|
+
function i32Buffer(values: number[]): ArrayBuffer {
|
|
19
|
+
return new Int32Array(values).buffer;
|
|
20
|
+
}
|
|
21
|
+
function u32Buffer(values: number[]): ArrayBuffer {
|
|
22
|
+
return new Uint32Array(values).buffer;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test("scalar f32 array - 'squares' canonical", () => {
|
|
26
|
+
const td = tableData(
|
|
27
|
+
entry(
|
|
28
|
+
`@buffer var<storage, read_write> result: array<f32, 4>;`,
|
|
29
|
+
"result",
|
|
30
|
+
f32Buffer([0, 1, 4, 9]),
|
|
31
|
+
),
|
|
32
|
+
);
|
|
33
|
+
expect(td.caption).toBe("result: array<f32, 4>");
|
|
34
|
+
expect(td.headers).toEqual(["", "value"]);
|
|
35
|
+
expect(td.rows).toEqual([
|
|
36
|
+
["0", "0.0"],
|
|
37
|
+
["1", "1.0"],
|
|
38
|
+
["2", "4.0"],
|
|
39
|
+
["3", "9.0"],
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("u32 scalar values", () => {
|
|
44
|
+
const td = tableData(
|
|
45
|
+
entry(
|
|
46
|
+
`@buffer var<storage, read_write> r: array<u32, 3>;`,
|
|
47
|
+
"r",
|
|
48
|
+
u32Buffer([1, 2, 3]),
|
|
49
|
+
),
|
|
50
|
+
);
|
|
51
|
+
expect(td.rows).toEqual([
|
|
52
|
+
["0", "1"],
|
|
53
|
+
["1", "2"],
|
|
54
|
+
["2", "3"],
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("i32 negative values", () => {
|
|
59
|
+
const td = tableData(
|
|
60
|
+
entry(
|
|
61
|
+
`@buffer var<storage, read_write> r: array<i32, 2>;`,
|
|
62
|
+
"r",
|
|
63
|
+
i32Buffer([-7, 42]),
|
|
64
|
+
),
|
|
65
|
+
);
|
|
66
|
+
expect(td.rows).toEqual([
|
|
67
|
+
["0", "-7"],
|
|
68
|
+
["1", "42"],
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("vec2f formats as (a, b)", () => {
|
|
73
|
+
const td = tableData(
|
|
74
|
+
entry(
|
|
75
|
+
`@buffer var<storage, read_write> v: array<vec2f, 2>;`,
|
|
76
|
+
"v",
|
|
77
|
+
f32Buffer([1, 2, 3, 4]),
|
|
78
|
+
),
|
|
79
|
+
);
|
|
80
|
+
expect(td.rows).toEqual([
|
|
81
|
+
["0", "(1.0, 2.0)"],
|
|
82
|
+
["1", "(3.0, 4.0)"],
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("struct with two vec2f fields (Particle)", () => {
|
|
87
|
+
const td = tableData(
|
|
88
|
+
entry(
|
|
89
|
+
`
|
|
90
|
+
struct Particle { pos: vec2f, vel: vec2f }
|
|
91
|
+
@buffer var<storage, read_write> p: array<Particle, 2>;
|
|
92
|
+
`,
|
|
93
|
+
"p",
|
|
94
|
+
f32Buffer([0, 0, 0.1, 0, 1, 2, 0.1, 0]),
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
expect(td.headers).toEqual(["", "pos", "vel"]);
|
|
98
|
+
expect(td.rows).toEqual([
|
|
99
|
+
["0", "(0.0, 0.0)", "(0.1, 0.0)"],
|
|
100
|
+
["1", "(1.0, 2.0)", "(0.1, 0.0)"],
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("single (non-array) scalar", () => {
|
|
105
|
+
const td = tableData(
|
|
106
|
+
entry(`@buffer var<storage, read_write> x: f32;`, "x", f32Buffer([1.5])),
|
|
107
|
+
);
|
|
108
|
+
expect(td.caption).toBe("x: f32");
|
|
109
|
+
expect(td.rows).toEqual([["0", "1.5"]]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("truncates beyond 256 rows and reports total", () => {
|
|
113
|
+
const N = 300;
|
|
114
|
+
const td = tableData(
|
|
115
|
+
entry(
|
|
116
|
+
`@buffer var<storage, read_write> big: array<u32, ${N}>;`,
|
|
117
|
+
"big",
|
|
118
|
+
u32Buffer(Array.from({ length: N }, (_, i) => i)),
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
expect(td.rows).toHaveLength(256);
|
|
122
|
+
expect(td.truncated).toEqual({ totalRows: N });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("f32 formatting: 4 sig figs for non-integers", () => {
|
|
126
|
+
const td = tableData(
|
|
127
|
+
entry(
|
|
128
|
+
`@buffer var<storage, read_write> f: array<f32, 3>;`,
|
|
129
|
+
"f",
|
|
130
|
+
f32Buffer([Math.PI, 1234.5678, 0.000123]),
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
expect(td.rows[0][1]).toBe("3.142");
|
|
134
|
+
expect(td.rows[1][1]).toBe("1235");
|
|
135
|
+
expect(td.rows[2][1]).toBe("0.000123");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("matrix element rejected as not table-renderable", () => {
|
|
139
|
+
expect(() =>
|
|
140
|
+
tableData(
|
|
141
|
+
entry(
|
|
142
|
+
`@buffer var<storage, read_write> m: array<mat4x4f, 1>;`,
|
|
143
|
+
"m",
|
|
144
|
+
new ArrayBuffer(64),
|
|
145
|
+
),
|
|
146
|
+
),
|
|
147
|
+
).toThrow(TypeShapeError);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("runtime-sized array derives rowCount from buffer byteLength", () => {
|
|
151
|
+
const td = tableData(
|
|
152
|
+
entry(
|
|
153
|
+
`@buffer var<storage, read_write> r: array<f32>;`,
|
|
154
|
+
"r",
|
|
155
|
+
f32Buffer([1, 2, 3]),
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
expect(td.caption).toBe("r: array<f32>");
|
|
159
|
+
expect(td.rows).toEqual([
|
|
160
|
+
["0", "1.0"],
|
|
161
|
+
["1", "2.0"],
|
|
162
|
+
["2", "3.0"],
|
|
163
|
+
]);
|
|
164
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** E2E tests for basics page (inline shaders, conditions, autoplay). */
|
|
2
|
+
import { expect, test } from "@playwright/test";
|
|
3
|
+
import {
|
|
4
|
+
expectCanvasSnapshot,
|
|
5
|
+
waitForFrame,
|
|
6
|
+
waitForNewFrame,
|
|
7
|
+
waitForWgslPlay,
|
|
8
|
+
} from "./E2eHelpers.ts";
|
|
9
|
+
|
|
10
|
+
test("wgsl-play component loads", async ({ page }, testInfo) => {
|
|
11
|
+
testInfo.setTimeout(10000); // Fast fail - 10s instead of 30s
|
|
12
|
+
|
|
13
|
+
const errors: string[] = [];
|
|
14
|
+
page.on("pageerror", err => errors.push(err.message));
|
|
15
|
+
|
|
16
|
+
await page.goto("/basics.html");
|
|
17
|
+
|
|
18
|
+
const defined = await page
|
|
19
|
+
.waitForFunction(() => customElements.get("wgsl-play"), { timeout: 5000 })
|
|
20
|
+
.then(() => true)
|
|
21
|
+
.catch(() => false);
|
|
22
|
+
|
|
23
|
+
if (!defined) {
|
|
24
|
+
const msg = errors.length
|
|
25
|
+
? `JS errors: ${errors.join("; ")}`
|
|
26
|
+
: "No JS errors captured - check if module loaded";
|
|
27
|
+
throw new Error(`wgsl-play custom element not defined. ${msg}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const hasShadow = await page.evaluate(
|
|
31
|
+
() => !!document.querySelector("#basicPlayer")?.shadowRoot,
|
|
32
|
+
);
|
|
33
|
+
expect(hasShadow).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("basic shader renders", async ({ page }) => {
|
|
37
|
+
await page.goto("/basics.html");
|
|
38
|
+
await waitForWgslPlay(page);
|
|
39
|
+
|
|
40
|
+
const basicPlayer = page.locator("#basicPlayer");
|
|
41
|
+
await expect(basicPlayer).toBeVisible();
|
|
42
|
+
|
|
43
|
+
const errorOverlay = basicPlayer.locator("text=Error");
|
|
44
|
+
await expect(errorOverlay).not.toBeVisible();
|
|
45
|
+
|
|
46
|
+
await expectCanvasSnapshot(page, "#basicPlayer", "basic-shader.png");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("project.conditions - shows green initially, red after setting RED", async ({
|
|
50
|
+
page,
|
|
51
|
+
}) => {
|
|
52
|
+
await page.goto("/basics.html");
|
|
53
|
+
await waitForFrame(page, "#conditionsPlayer");
|
|
54
|
+
|
|
55
|
+
await expectCanvasSnapshot(
|
|
56
|
+
page,
|
|
57
|
+
"#conditionsPlayer",
|
|
58
|
+
"conditions-initial-green.png",
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
await page.click("#load-red-condition");
|
|
62
|
+
await waitForNewFrame(page, "#conditionsPlayer");
|
|
63
|
+
|
|
64
|
+
await expectCanvasSnapshot(
|
|
65
|
+
page,
|
|
66
|
+
"#conditionsPlayer",
|
|
67
|
+
"conditions-after-red.png",
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("no critical console errors on basic load", async ({ page }) => {
|
|
72
|
+
const errors: string[] = [];
|
|
73
|
+
page.on("console", msg => {
|
|
74
|
+
if (msg.type() === "error") {
|
|
75
|
+
const text = msg.text();
|
|
76
|
+
if (!text.includes("favicon")) errors.push(text);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await page.goto("/basics.html");
|
|
81
|
+
await waitForFrame(page, "#basicPlayer");
|
|
82
|
+
|
|
83
|
+
const criticalErrors = errors.filter(
|
|
84
|
+
e => !e.includes("favicon") && !e.includes("404"),
|
|
85
|
+
);
|
|
86
|
+
expect(criticalErrors).toEqual([]);
|
|
87
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/** E2E tests for compute page (single-workgroup compute + readback panel). */
|
|
2
|
+
import { expect, test } from "@playwright/test";
|
|
3
|
+
import {
|
|
4
|
+
readResultsPanel,
|
|
5
|
+
waitForResults,
|
|
6
|
+
waitForWgslPlay,
|
|
7
|
+
} from "./E2eHelpers.ts";
|
|
8
|
+
|
|
9
|
+
test.beforeEach(async ({ page }) => {
|
|
10
|
+
await page.goto("/compute.html");
|
|
11
|
+
await waitForWgslPlay(page);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("squares: single @buffer dispatched once", async ({ page }) => {
|
|
15
|
+
await waitForResults(page, "#squaresPlayer");
|
|
16
|
+
const data = await readResultsPanel(page, "#squaresPlayer");
|
|
17
|
+
expect(data).toEqual([
|
|
18
|
+
{
|
|
19
|
+
caption: "result: array<f32, 8>",
|
|
20
|
+
headers: ["", "value"],
|
|
21
|
+
rows: [
|
|
22
|
+
["0", "0.0"],
|
|
23
|
+
["1", "1.0"],
|
|
24
|
+
["2", "4.0"],
|
|
25
|
+
["3", "9.0"],
|
|
26
|
+
["4", "16.0"],
|
|
27
|
+
["5", "25.0"],
|
|
28
|
+
["6", "36.0"],
|
|
29
|
+
["7", "49.0"],
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("two-buffers (input + output), both zero-initialized", async ({
|
|
36
|
+
page,
|
|
37
|
+
}) => {
|
|
38
|
+
await waitForResults(page, "#prefixSumPlayer");
|
|
39
|
+
const data = await readResultsPanel(page, "#prefixSumPlayer");
|
|
40
|
+
const zeros4 = [
|
|
41
|
+
["0", "0.0"],
|
|
42
|
+
["1", "0.0"],
|
|
43
|
+
["2", "0.0"],
|
|
44
|
+
["3", "0.0"],
|
|
45
|
+
];
|
|
46
|
+
expect(data).toEqual([
|
|
47
|
+
{ caption: "input: array<f32, 4>", headers: ["", "value"], rows: zeros4 },
|
|
48
|
+
{ caption: "output: array<f32, 4>", headers: ["", "value"], rows: zeros4 },
|
|
49
|
+
]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("struct array renders one column per field", async ({ page }) => {
|
|
53
|
+
await waitForResults(page, "#particlesPlayer");
|
|
54
|
+
const data = await readResultsPanel(page, "#particlesPlayer");
|
|
55
|
+
expect(data).toEqual([
|
|
56
|
+
{
|
|
57
|
+
caption: "particles: array<Particle, 4>",
|
|
58
|
+
headers: ["", "pos", "vel"],
|
|
59
|
+
rows: [
|
|
60
|
+
["0", "(0.0, 0.0)", "(0.0, 0.0)"],
|
|
61
|
+
["1", "(1.0, 2.0)", "(0.1, 0.0)"],
|
|
62
|
+
["2", "(2.0, 4.0)", "(0.2, 0.0)"],
|
|
63
|
+
["3", "(3.0, 6.0)", "(0.3, 0.0)"],
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("slider drives re-dispatch", async ({ page }) => {
|
|
70
|
+
await waitForResults(page, "#sliderPlayer");
|
|
71
|
+
|
|
72
|
+
// Move the slider; assert that re-dispatch lands the right values.
|
|
73
|
+
await page.evaluate(() => {
|
|
74
|
+
const player = document.querySelector("#sliderPlayer") as HTMLElement;
|
|
75
|
+
const input = player.shadowRoot?.querySelector(
|
|
76
|
+
'input[type="range"]',
|
|
77
|
+
) as HTMLInputElement | null;
|
|
78
|
+
if (!input) throw new Error("slider not found");
|
|
79
|
+
input.value = "8";
|
|
80
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// After re-run with k=8, expect result[7] === 7 * 8 === 56.0
|
|
84
|
+
await page.waitForFunction(() => {
|
|
85
|
+
const el = document.querySelector("#sliderPlayer") as HTMLElement;
|
|
86
|
+
const cell = el?.shadowRoot?.querySelector(
|
|
87
|
+
".results-panel tbody tr:last-of-type td:last-child",
|
|
88
|
+
);
|
|
89
|
+
return cell?.textContent === "56.0";
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const data = await readResultsPanel(page, "#sliderPlayer");
|
|
93
|
+
expect(data).toEqual([
|
|
94
|
+
{
|
|
95
|
+
caption: "result: array<f32, 8>",
|
|
96
|
+
headers: ["", "value"],
|
|
97
|
+
rows: [
|
|
98
|
+
["0", "0.0"],
|
|
99
|
+
["1", "8.0"],
|
|
100
|
+
["2", "16.0"],
|
|
101
|
+
["3", "24.0"],
|
|
102
|
+
["4", "32.0"],
|
|
103
|
+
["5", "40.0"],
|
|
104
|
+
["6", "48.0"],
|
|
105
|
+
["7", "56.0"],
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("runtime-sized array: host-sized default renders 256 f32 rows", async ({
|
|
112
|
+
page,
|
|
113
|
+
}) => {
|
|
114
|
+
await waitForResults(page, "#runtimeArrayPlayer");
|
|
115
|
+
const data = await readResultsPanel(page, "#runtimeArrayPlayer");
|
|
116
|
+
expect(data).toHaveLength(1);
|
|
117
|
+
expect(data[0].caption).toBe("result: array<f32>");
|
|
118
|
+
// 1024-byte default / 4 bytes per f32 = 256 rows; shader writes only row 0.
|
|
119
|
+
expect(data[0].rows[0]).toEqual(["0", "1.0"]);
|
|
120
|
+
expect(data[0].rows[1]).toEqual(["1", "0.0"]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const rejections: { id: string; pattern: RegExp }[] = [
|
|
124
|
+
{
|
|
125
|
+
id: "#rejectedTwoComputePlayer",
|
|
126
|
+
pattern: /compute mode requires exactly one @compute entry point/,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
id: "#rejectedMixedPlayer",
|
|
130
|
+
pattern: /mixed compute and fragment entry points/,
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
id: "#rejectedMatrixPlayer",
|
|
134
|
+
pattern: /matrices are not supported/,
|
|
135
|
+
},
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
for (const { id, pattern } of rejections) {
|
|
139
|
+
test(`rejection: ${id}`, async ({ page }) => {
|
|
140
|
+
await page.waitForFunction(sel => {
|
|
141
|
+
const status = document.querySelector(`${sel}-status`) as HTMLElement;
|
|
142
|
+
return status?.dataset.rejected === "true";
|
|
143
|
+
}, id);
|
|
144
|
+
const status = await page.locator(`${id}-status`).textContent();
|
|
145
|
+
expect(status).toMatch(pattern);
|
|
146
|
+
});
|
|
147
|
+
}
|