test(static): E2E — static boot with no API + dream shader survives cross-origin CORS
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -32,3 +32,24 @@ npm test
|
||||
|
||||
`playwright.config.ts` starts uvicorn on port 8099 automatically (`reuseExistingServer`
|
||||
locally) and points the tests at it.
|
||||
|
||||
## Static-build E2E (Cloudflare publish)
|
||||
|
||||
`tests/static-build.spec.ts` verifies the **built static site** (Cloudflare publish
|
||||
— see `deploy/cloudflare/README.md`): it boots with NO `/api/*` server (baked JSON),
|
||||
and the WebGL dream shader runs on **cross-origin** media without tainting the GL
|
||||
texture (the CORS path R2 will use in production).
|
||||
|
||||
It uses `playwright.static.config.ts` (no uvicorn `webServer`) and a dual static
|
||||
server, `serve-static.mjs`, that serves the built `dist/` (app, :8077) and
|
||||
`dist-media/` (media with CORS + Range, :8078):
|
||||
|
||||
```bash
|
||||
# from the repo root: build with the media base pointed at the local CORS server
|
||||
.venv/bin/python -m tools.build_static --media-base http://localhost:8078/
|
||||
|
||||
# from this directory (simulator/e2e): start the dual server, then run the spec
|
||||
DIST_DIR="$PWD/../../dist" MEDIA_DIR_E2E="$PWD/../../dist-media" node serve-static.mjs &
|
||||
npx playwright test --config playwright.static.config.ts
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
// Static-build E2E: NO webServer — the built dist/ + dist-media/ are served by
|
||||
// serve-static.mjs (started manually, see README.md), so there is no uvicorn/API.
|
||||
// Run: npx playwright test --config playwright.static.config.ts
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
testMatch: /static-build\.spec\.ts/,
|
||||
timeout: 60_000,
|
||||
use: { actionTimeout: 10_000 },
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
// Serves the built dist/ (app) and dist-media/ (media, with CORS + Range) on two
|
||||
// ports — a local stand-in for Pages + R2, so the static-build E2E exercises the
|
||||
// cross-origin media path exactly as production will (CORS for the WebGL dream
|
||||
// shader, Range for video scrubbing).
|
||||
//
|
||||
// Env: DIST_DIR (app root), MEDIA_DIR_E2E (media root). Ports: app 8077, media 8078.
|
||||
import http from "node:http";
|
||||
import { createReadStream, statSync } from "node:fs";
|
||||
import { join, extname, normalize } from "node:path";
|
||||
|
||||
const TYPES = {
|
||||
".html": "text/html", ".js": "text/javascript", ".json": "application/json",
|
||||
".css": "text/css", ".mp4": "video/mp4", ".mp3": "audio/mpeg",
|
||||
};
|
||||
|
||||
function serve(root, port, cors, indexFallback) {
|
||||
http.createServer((req, res) => {
|
||||
if (cors) { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Accept-Ranges", "bytes"); }
|
||||
let p = decodeURIComponent(req.url.split("?")[0]);
|
||||
if (p.endsWith("/")) p += "index.html";
|
||||
// contain to root (no path traversal)
|
||||
const file = normalize(join(root, p));
|
||||
if (!file.startsWith(normalize(root))) { res.writeHead(403); return res.end("forbidden"); }
|
||||
try {
|
||||
const st = statSync(file);
|
||||
res.setHeader("Content-Type", TYPES[extname(file)] || "application/octet-stream");
|
||||
const range = req.headers.range; // honor Range for video scrub
|
||||
if (range && /^bytes=/.test(range)) {
|
||||
const [s, e] = range.replace("bytes=", "").split("-");
|
||||
const start = +s, end = e ? +e : st.size - 1;
|
||||
res.writeHead(206, {
|
||||
"Content-Range": `bytes ${start}-${end}/${st.size}`,
|
||||
"Content-Length": end - start + 1,
|
||||
});
|
||||
createReadStream(file, { start, end }).pipe(res);
|
||||
} else {
|
||||
res.writeHead(200, { "Content-Length": st.size });
|
||||
createReadStream(file).pipe(res);
|
||||
}
|
||||
} catch {
|
||||
res.writeHead(404); res.end("not found");
|
||||
}
|
||||
}).listen(port);
|
||||
}
|
||||
|
||||
serve(process.env.DIST_DIR, 8077, false);
|
||||
serve(process.env.MEDIA_DIR_E2E, 8078, true);
|
||||
console.log("static app :8077 media(cors) :8078");
|
||||
@@ -0,0 +1,30 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// Runs against the BUILT static site: dist/ served on :8077, media (CORS) on :8078
|
||||
// by serve-static.mjs. Build with `--media-base http://localhost:8078/` so config.js
|
||||
// points media cross-origin — the production CORS path, locally. See README.md.
|
||||
const APP = "http://localhost:8077/human-experience-simulator/";
|
||||
|
||||
test("boots fully static — no /api calls, ring + clips load from baked JSON", async ({ page }) => {
|
||||
const apiCalls: string[] = [];
|
||||
page.on("request", (r) => { if (r.url().includes("/api/")) apiCalls.push(r.url()); });
|
||||
await page.goto(APP);
|
||||
await expect(page.locator("#vid")).toBeVisible({ timeout: 30_000 });
|
||||
expect(apiCalls, "static build must not call /api/*").toEqual([]);
|
||||
});
|
||||
|
||||
test("dream shader runs on cross-origin media without tainting the GL texture", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(String(e)));
|
||||
await page.goto(APP);
|
||||
await expect(page.locator("#vid")).toBeVisible({ timeout: 30_000 });
|
||||
// crank the Feel (Right) knob to engage the dream, let a few frames render
|
||||
await page.evaluate(() => {
|
||||
const r = document.getElementById("right") as HTMLInputElement;
|
||||
r.value = "4";
|
||||
r.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await page.waitForTimeout(2000);
|
||||
// a tainted GL texture throws SecurityError on draw; assert none surfaced
|
||||
expect(errors.filter((e) => /SecurityError|tainted|cross-origin/i.test(e))).toEqual([]);
|
||||
});
|
||||
Reference in New Issue
Block a user