Merge design/cloudflare-static-publish into main: Cloudflare static publish + CC credits

Integrates the fully-static Cloudflare publish line (live at
benstull.art/human-experience-simulator) into main: the static build (Pages + R2,
manifest-only media tree, baked API JSON), the "Run simulation" start button, the
public CC-BY/BY-SA credits page with the "License & reuse" CC BY-SA 4.0 derivative
declaration (Option A + D1), the repeatable Cloudflare deploy script, and assorted
load/audio fixes (progressive boot, scaleAudioUrl via mediaBase).

Clean auto-merge with main's per-clip affect/feelings + i18n work — only app.js and
altitude-lock.spec.ts overlapped, both auto-resolved. Verified green on the merged
tree: node 29, pytest 315 passed / 2 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-30 09:42:44 -07:00
30 changed files with 2052 additions and 58 deletions
+21
View File
@@ -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
```
+11
View File
@@ -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 },
});
+48
View File
@@ -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");
+16 -4
View File
@@ -83,15 +83,27 @@ test("audio is a 0-10 level dial", async ({ page }) => {
expect(ctl.max).toBe("10");
});
test("boots silent (video off, audio 0); turning video on couples audio to 3/10", async ({ page }) => {
test("boots silent (video off, audio 0); turning video on couples audio to 2/10 and dismisses the Run prompt", async ({ page }) => {
await boot(page);
// Boots SILENT — no un-gestured auto-start, so the browser autoplay block never bites.
expect(await page.evaluate(() => (document.querySelector("#visual") as HTMLInputElement).checked)).toBe(false);
expect(await page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("0");
// Turning Video on (a click gesture) lifts the audio dial to a gentle 3/10.
// "Run simulation" is revealed once preload finishes — the obvious starting point.
expect(await page.evaluate(() => document.querySelector("#run-sim")?.classList.contains("hidden"))).toBe(false);
// Turning Video on directly (a click gesture) lifts the audio dial to a gentle 2/10
// and dismisses the Run prompt.
await enableVideo(page);
await expect.poll(() => page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("3");
expect(await page.evaluate(() => document.querySelector("#audio-level-val")?.textContent)).toBe("3");
await expect.poll(() => page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("2");
expect(await page.evaluate(() => document.querySelector("#audio-level-val")?.textContent)).toBe("2");
expect(await page.evaluate(() => document.querySelector("#run-sim")?.classList.contains("hidden"))).toBe(true);
});
test("the Run simulation button starts video, snaps audio to 2/10, and dismisses itself", async ({ page }) => {
await boot(page);
await page.click("#run-sim");
await expect.poll(() => page.evaluate(() => (document.querySelector("#visual") as HTMLInputElement).checked)).toBe(true);
await expect.poll(() => page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("2");
expect(await page.evaluate(() => document.querySelector("#run-sim")?.classList.contains("hidden"))).toBe(true);
});
test("dragging the dial scrubs morph currentTime and audio gains", async ({ page }) => {
+30
View File
@@ -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([]);
});