split tooling: line-map splitter, post edits, node checks, harness fragments as modules over window.g, a workflow that performs a step
The harness fragments now run as inline modules (after app.js) and stub the renderer through render.setFetcher; harness.sh's loop mode goes over http + DevTools like resume (file:// cannot load modules; --dump-dom also hangs on current Chrome). No page change yet. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG
This commit is contained in:
1 parent
0786964677
commit
f855ee15b7
9 files changed
+184
-37
No files matched your search
@@ -0,0 +1,12 @@
|
||||
// tools/check-pure.mjs: covers.js and render.js's generators load and run in node (a canvas stub stands in for the DOM).
|
||||
const noop = () => {}; const ctx = new Proxy({}, { get: (_, k) => k === "measureText" ? () => ({ width: 10 }) : k === "createLinearGradient" || k === "createRadialGradient" ? () => ({ addColorStop: noop }) : noop, set: () => true });
|
||||
const canvas = () => ({ width: 64, height: 64, getContext: () => ctx });
|
||||
globalThis.document = { createElement: canvas, documentElement: {} }; globalThis.getComputedStyle = () => ({ getPropertyValue: () => "" });
|
||||
globalThis.localStorage = { getItem: () => null, setItem: noop }; globalThis.Audio = class { constructor() {} };
|
||||
const covers = await import("../covers.js");
|
||||
for (const look of covers.LOOKS) covers.drawCover(canvas(), { seed: "s", title: "t", tags: "x", bpm: 100, key: "A minor", energy: 0.5, look });
|
||||
covers.drawCover(canvas(), { seed: "s", title: "t", tags: "x", bpm: 100, key: "C", energy: 0.5, break: "ident" });
|
||||
const render = await import("../render.js");
|
||||
const w = render.wavBlob(new Int16Array(80), 8000); if (w.size !== 44 + 160) throw new Error("wavBlob size " + w.size);
|
||||
const n = await render.makeNoise("pink", 1), t = await render.makeTone({ kind: "binaural", hz: 200, beat: 10 }, 1);
|
||||
console.log("covers ok, wav", w.size, "noise", n.size, "tone", t.size);
|
||||
@@ -9,15 +9,17 @@ link. Expected: the track is loaded but autoplay is refused, so the player waits
|
||||
and the next track cooks behind it. The other half — the tap starting it — needs a real user activation, which
|
||||
headless Chrome cannot fake; it is documented in ARCHITECTURE.md, not asserted here.</p>
|
||||
<!-- harness -->
|
||||
<script>
|
||||
<script type="module">
|
||||
/* a module so it runs after app.js (module scripts are deferred, in order); the page is reached through window.g */
|
||||
const { state, openStation, cookNext, crackle, db, wavBlob, render } = window.g, hash32 = window.hash32;
|
||||
try {
|
||||
let n = 0;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
venice = async function (path, body, { raw = false } = {}) {
|
||||
render.setFetcher(async function (path, body, { raw = false } = {}) {
|
||||
if (path.includes("chat")) return { choices: [{ message: { content: JSON.stringify({ title: "T" + (++n), tags: "lo-fi, piano, instrumental", lyrics: "[verse]\nla\n[chorus]\nlo", bpm: 80, key: "C major", energy: 0.4, seconds: 30, meter: "4/4", station: "Resume FM", line: "hi" }) } }] };
|
||||
if (path === "/render") { await sleep(50); const b = wavBlob(new Int16Array(8000 * 2), 8000); return new Response(b, { headers: { "content-type": "audio/wav", "x-cost": "0.001", "x-renderer": "test" } }); }
|
||||
throw new Error("unexpected " + path);
|
||||
};
|
||||
});
|
||||
const log = []; const dd = document.createElement("pre"); dd.id = "diag"; document.body.append(dd);
|
||||
const L = (m) => { log.push(m); dd.textContent = log.join("\n"); };
|
||||
const S = (tag) => L(tag + "\n phase=" + state.phase + " needTap=" + !!state.needTap + " tap=" + document.querySelector("#cover").classList.contains("tap") + " veil=" + JSON.stringify(document.querySelector("#cover .veil").textContent) + " paused=" + state.audio.paused + " playing=" + state.playing + " holding=" + !!state.holding + " cur=" + (state.current && state.current.spec.title) + " curblob=" + !!(state.current && state.current.blob) + " next=" + (state.nextJob && state.nextJob.spec.title) + " nextblob=" + !!(state.nextJob && state.nextJob.blob) + " crackle=" + crackle.on);
|
||||
|
||||
+5
-3
@@ -3,14 +3,16 @@
|
||||
<title>harness</title>
|
||||
<p>This file is a fragment: tools/harness.sh appends everything from the marker below to index.html and runs it.</p>
|
||||
<!-- harness -->
|
||||
<script>
|
||||
<script type="module">
|
||||
/* a module so it runs after app.js (module scripts are deferred, in order); the page is reached through window.g */
|
||||
const { state, openStation, cookNext, crackle, db, wavBlob, render } = window.g, hash32 = window.hash32;
|
||||
try {
|
||||
let renderDelay = 50, n = 0;
|
||||
venice = async function (path, body, { raw = false } = {}) {
|
||||
render.setFetcher(async function (path, body, { raw = false } = {}) {
|
||||
if (path.includes("chat")) return { choices: [{ message: { content: JSON.stringify({ title: "T" + (++n), tags: "lo-fi, piano, instrumental", lyrics: "[verse]\nla\n[chorus]\nlo", bpm: 80, key: "C major", energy: 0.4, seconds: 30, meter: "4/4", station: "Test FM", line: "hi" }) } }] };
|
||||
if (path === "/render") { await new Promise((r) => setTimeout(r, renderDelay)); const b = wavBlob(new Int16Array(8000 * 2), 8000); return new Response(b, { headers: { "content-type": "audio/wav", "x-cost": "0.001", "x-renderer": "test" } }); }
|
||||
throw new Error("unexpected " + path);
|
||||
};
|
||||
});
|
||||
const log = []; const dd = document.createElement("pre"); dd.id = "diag"; document.body.append(dd);
|
||||
const L = (m) => { log.push(m); dd.textContent = log.join("\n"); };
|
||||
const S = (tag) => L(tag + "\n phase=" + state.phase + " src=" + String(state.audio.src).slice(5, 14) + " paused=" + state.audio.paused + " playing=" + state.playing + " holding=" + !!state.holding + " cur=" + (state.current && state.current.spec.title) + " next=" + (state.nextJob && state.nextJob.spec.title) + " nextblob=" + !!(state.nextJob && state.nextJob.blob) + " crackle=" + crackle.on + " loop=" + state.audio.loop);
|
||||
|
||||
+17
-31
@@ -2,7 +2,8 @@
|
||||
# tools/harness.sh: the playback regression run. Appends a harness fragment's script to index.html, runs it in headless
|
||||
# Chrome, prints one state line per step. Needs Chrome/Chromium and python3.
|
||||
#
|
||||
# sh tools/harness.sh # tools/harness.html: the station loop with autoplay allowed, from file://
|
||||
# sh tools/harness.sh # tools/harness.html: the station loop with autoplay allowed, served over http
|
||||
# # (ES modules need it) and driven over the DevTools protocol
|
||||
# sh tools/harness.sh resume # tools/harness-resume.html: resume a stored station with autoplay BLOCKED, served
|
||||
# # over http (IndexedDB needs it) and driven in real time over the DevTools protocol
|
||||
# # (tools/harness-cdp.py); expects the TAP TO PLAY wait
|
||||
@@ -32,33 +33,18 @@ h = pathlib.Path(sys.argv[2]).read_text()
|
||||
script = h[h.index("<!-- harness -->"):]
|
||||
(out / "run.html").write_text(page.replace("</body>", script + "</body>"))
|
||||
EOF
|
||||
cp brain.js "$out/brain.js"
|
||||
if [ "$MODE" = resume ]; then
|
||||
# a fresh profile and a port picked by the OS, so no run inherits another's IndexedDB
|
||||
PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1])')
|
||||
python3 -m http.server "$PORT" --bind 127.0.0.1 --directory "$out" >/dev/null 2>&1 </dev/null &
|
||||
SRV=$!
|
||||
trap 'kill $SRV 2>/dev/null || true; rm -rf "$out" "$prof"' EXIT INT TERM
|
||||
n=0
|
||||
until curl -fs -o /dev/null --max-time 2 "http://127.0.0.1:$PORT/run.html"; do
|
||||
n=$((n + 1)); [ "$n" -lt 40 ] || { echo "the local server on port $PORT never answered" >&2; exit 1; }; sleep 0.5
|
||||
done
|
||||
URL="http://127.0.0.1:$PORT/run.html"
|
||||
else
|
||||
trap 'rm -rf "$out" "$prof"' EXIT INT TERM
|
||||
URL="file://$out/run.html"
|
||||
fi
|
||||
if [ "$MODE" = resume ]; then
|
||||
# real time, over the DevTools protocol: --dump-dom's virtual time doesn't wait for IndexedDB (see harness-cdp.py)
|
||||
# shellcheck disable=SC2086
|
||||
python3 tools/harness-cdp.py "$CHROME" "$URL" 30 ${CHROME_FLAGS:-}
|
||||
else
|
||||
# shellcheck disable=SC2086
|
||||
"$CHROME" --headless=new --disable-gpu --user-data-dir="$prof" $AUTOPLAY --virtual-time-budget=25000 ${CHROME_FLAGS:-} --dump-dom "$URL" 2>/dev/null > "$out/dom.html" || true
|
||||
python3 - "$out/dom.html" <<'EOF'
|
||||
import sys, re, html
|
||||
s = open(sys.argv[1]).read()
|
||||
m = re.search(r'<pre id="diag">(.*?)</pre>', s, re.S)
|
||||
print(html.unescape(m.group(1)) if m else "no diag: the harness did not run")
|
||||
EOF
|
||||
fi
|
||||
cp ./*.js "$out/"; [ -d ui ] && mkdir -p "$out/ui" && cp ui/*.js "$out/ui/" # the modules and brain.js, beside the page
|
||||
# a fresh profile and a port picked by the OS, so no run inherits another's IndexedDB
|
||||
PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1])')
|
||||
python3 -m http.server "$PORT" --bind 127.0.0.1 --directory "$out" >/dev/null 2>&1 </dev/null &
|
||||
SRV=$!
|
||||
trap 'kill $SRV 2>/dev/null || true; rm -rf "$out" "$prof"' EXIT INT TERM
|
||||
n=0
|
||||
until curl -fs -o /dev/null --max-time 2 "http://127.0.0.1:$PORT/run.html"; do
|
||||
n=$((n + 1)); [ "$n" -lt 40 ] || { echo "the local server on port $PORT never answered" >&2; exit 1; }; sleep 0.5
|
||||
done
|
||||
URL="http://127.0.0.1:$PORT/run.html"
|
||||
# real time, over the DevTools protocol: --dump-dom's virtual time doesn't wait for IndexedDB, and file:// can't load
|
||||
# modules (see harness-cdp.py)
|
||||
# shellcheck disable=SC2086
|
||||
python3 tools/harness-cdp.py "$CHROME" "$URL" 30 $AUTOPLAY ${CHROME_FLAGS:-}
|
||||
@@ -0,0 +1,5 @@
|
||||
/* what the page shares with classic scripts loaded after it (brain.js, the harness fragments): window.g for tooling,
|
||||
and the exact names brain.js uses today, as globals. Everything else stays inside the modules. */
|
||||
window.g = { state, openStation, cookNext, crackle, hooks, db, wavBlob, $, hash32, render: { setFetcher } };
|
||||
Object.assign(window, { state, hooks, $, db, playJob, drawCover, hash32, prng, keyHue, KEYS, reactionWeight, drawTaste, openTaste, reactionEmoji });
|
||||
Object.defineProperty(window, "seq", { get: () => ids.seq, set: (v) => { ids.seq = v; } }); // brain.js does ++seq
|
||||
@@ -0,0 +1 @@
|
||||
[[403, "core.js"], [436, "covers.js"], [637, "player.js"], [712, "render.js"], [720, "player.js"], [784, "render.js"], [799, "dj.js"], [866, "render.js"], [867, "dj.js"], [878, "render.js"], [935, "dj.js"], [960, "render.js"], [1013, "store.js"], [1035, "ui/library.js"], [1051, "ui/sheet.js"], [1187, "app.js"], [1218, "core.js"], [1219, "app.js"], [1240, "store.js"], [1251, "app.js"], [1252, "core.js"], [1253, "store.js"], [1258, "app.js"], [1279, "dj.js"], [1313, "app.js"], [1331, "player.js"], [1461, "ui/player.js"], [1488, "player.js"], [1556, "ui/player.js"], [1561, "ui/library.js"], [1573, "ui/player.js"], [1587, "core.js"], [1588, "ui/player.js"], [1644, "app.js"], [1658, "ui/player.js"], [1762, "player.js"], [1775, "app.js"], [1802, "player.js"], [1828, "ui/player.js"], [1875, "app.js"], [1885, "ui/player.js"], [1890, "app.js"], [1894, "ui/library.js"], [1973, "store.js"], [1985, "ui/library.js"], [2023, "mashup.js"], [2047, "dj.js"], [2066, "mashup.js"], [2079, "ui/library.js"], [2095, "app.js"], [2112, "ui/taste.js"], [2186, "app.js"]]
|
||||
@@ -0,0 +1,26 @@
|
||||
# tools/split-post.py: the hand edits after split.js wrote a step. Idempotent.
|
||||
import re, pathlib, sys
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
files = [p for p in list(ROOT.glob("*.js")) + list(ROOT.glob("ui/*.js")) if p.name not in ("brain.js", "sw.js")] + [ROOT / "index.html"]
|
||||
for p in files:
|
||||
s = t = p.read_text()
|
||||
t = t.replace("export let seq = 0;", "export const ids = { seq: 0 }; // job ids; brain.js reaches it as window.seq")
|
||||
t = t.replace("let seq = 0;", "const ids = { seq: 0 }; // job ids; brain.js reaches it as window.seq")
|
||||
t = re.sub(r"\+\+seq\b", "++ids.seq", t)
|
||||
t = re.sub(r"^(import \{[^}]*?)\bseq\b", r"\1ids", t, flags=re.M)
|
||||
if "async function venice(path" in t:
|
||||
t = t.replace("export async function venice(path, body, { raw = false } = {}) {", "async function veniceFetch(path, body, { raw = false } = {}) {", 1)
|
||||
t = t.replace("async function venice(path, body, { raw = false } = {}) {", "async function veniceFetch(path, body, { raw = false } = {}) {", 1)
|
||||
j = t.index("\n}\n", t.index("async function veniceFetch")) + 3
|
||||
t = t[:j] + "/* the fetcher is injectable: the harness fragments stub it (render.setFetcher); every call goes through venice() */\nlet fetcher = veniceFetch;\nexport const setFetcher = (fn) => { fetcher = fn || veniceFetch; };\nexport const venice = (...a) => fetcher(...a);\n" + t[j:]
|
||||
t = t.replace("\nasync function makeTone(", "\nexport async function makeTone(").replace("\nasync function makeNoise(", "\nexport async function makeNoise(")
|
||||
t = t.replace('import { crackle } from "./player.js";\n', "") # a regex literal in noiseSpec mentions crackle; not a use
|
||||
if t != s: p.write_text(t)
|
||||
h = ROOT / "index.html"; s = h.read_text()
|
||||
s = s.replace('<script src="brain.js"></script>', '<script src="brain.js" defer></script>') # after the module, which is deferred too
|
||||
h.write_text(s)
|
||||
sw = ROOT / "sw.js"; s = sw.read_text()
|
||||
mods = sorted(str(p.relative_to(ROOT)) for p in list(ROOT.glob("*.js")) + list(ROOT.glob("ui/*.js")) if p.name not in ("brain.js", "sw.js"))
|
||||
s = re.sub(r'const FILES = \["/", "/index.html",( "/[a-z/]+\.js",)*', 'const FILES = ["/", "/index.html",' + "".join(f' "/{m}",' for m in mods), s)
|
||||
sw.write_text(s)
|
||||
print("post ok:", mods)
|
||||
@@ -0,0 +1,67 @@
|
||||
// tools/split.js — one-off: cut index.html's script into ES modules by line ranges. Analysis mode by default; WRITE=1 writes.
|
||||
// STEP=a.js,b.js extracts only those modules and leaves the rest as an inline module in index.html (the intermediate commits).
|
||||
const fs = require("fs");
|
||||
const src = fs.readFileSync("index.html", "utf8").split("\n");
|
||||
const S = 402, E = 2199; // <script> at S (1-based), </script> at E
|
||||
const cuts = JSON.parse(fs.readFileSync("tools/split-map.json", "utf8")); // [[startLine, "file"], ...] sorted
|
||||
const body = src.slice(S, E - 1); // lines S+1 .. E-1 (1-based)
|
||||
const at = (n) => n - (S + 1); // index into body
|
||||
const chunks = [];
|
||||
for (let i = 0; i < cuts.length; i++) {
|
||||
const [a, f] = cuts[i], b = i + 1 < cuts.length ? cuts[i + 1][0] : E;
|
||||
chunks.push({ file: f, from: a, to: b - 1, lines: body.slice(at(a), at(b)) });
|
||||
}
|
||||
const STEP = process.env.STEP ? new Set(process.env.STEP.split(",")) : null;
|
||||
for (const c of chunks) if (STEP && !STEP.has(c.file)) c.file = "index.html";
|
||||
const mods = {};
|
||||
for (const c of chunks) (mods[c.file] ||= { file: c.file, lines: [], decl: new Map(), lets: new Set() }).lines.push(...c.lines, "");
|
||||
const declRe = /^(?:async\s+)?function\s+([A-Za-z_$][\w$]*)|^(const|let)\s+(.+?)(?:\s*=|;|$)/;
|
||||
for (const m of Object.values(mods)) {
|
||||
m.lines.forEach((ln, i) => {
|
||||
let r = declRe.exec(ln); if (!r) return;
|
||||
if (r[1]) m.decl.set(r[1], i);
|
||||
else {
|
||||
// const a = 1, b = 2; const { x } = y; const [a, b] = z
|
||||
const head = ln.slice(r[2].length).trim();
|
||||
const names = [...head.matchAll(/(?:^|,\s*)([A-Za-z_$][\w$]*)\s*=/g)].map((x) => x[1]);
|
||||
if (/^\s*[\[{]/.test(head)) for (const n of head.slice(0, head.indexOf("=")).match(/[A-Za-z_$][\w$]*/g) || []) names.push(n);
|
||||
if (/=>|function/.test(head)) names.length = 1;
|
||||
for (const n of names) { m.decl.set(n, i); if (r[2] === "let") m.lets.add(n); }
|
||||
}
|
||||
});
|
||||
}
|
||||
const owner = {}; for (const m of Object.values(mods)) for (const n of m.decl.keys()) { if (owner[n]) console.error("DUP", n, owner[n], m.file); owner[n] = m.file; }
|
||||
const strip = (s) => s.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*|`(?:\\.|[^`\\])*`|"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'/g, (t) => t[0] === "`" ? (t.match(/\$\{[^}]*\}/g) || []).map((x) => x.slice(2, -1)).join(" ") : " ");
|
||||
const GLOBALS = fs.readFileSync("tools/split-globals.txt", "utf8");
|
||||
const report = {};
|
||||
for (const m of Object.values(mods)) {
|
||||
const text = strip(m.lines.join("\n") + (m.file === "app.js" || m.file === "index.html" ? GLOBALS : ""));
|
||||
const used = new Set(text.replace(/(?<![.\w$])[A-Za-z_$][\w$]*(?=:)/g, " ").match(/(?<![.\w$])[A-Za-z_$][\w$]*/g) || []);
|
||||
m.imports = {};
|
||||
for (const n of used) if (owner[n] && owner[n] !== m.file) (m.imports[owner[n]] ||= new Set()).add(n);
|
||||
// reassignments of foreign lets: name = ... / name++ / name += (not inside a declaration)
|
||||
m.assignsForeign = [];
|
||||
for (const [f, names] of Object.entries(m.imports)) for (const n of names) if (mods[f].lets.has(n) && new RegExp(`(?<![.\\w$])${n.replace("$", "\\$")}\\s*(=[^=]|\\+\\+|--|[+\\-*/]=)`).test(text)) m.assignsForeign.push(n);
|
||||
report[m.file] = { exports: [], imports: Object.fromEntries(Object.entries(m.imports).map(([f, s]) => [f, [...s]])), assignsForeign: m.assignsForeign };
|
||||
}
|
||||
for (const m of Object.values(mods)) report[m.file].exports = [...m.decl.keys()].filter((n) => Object.values(mods).some((o) => o !== m && o.imports[m.file] && o.imports[m.file].has(n)));
|
||||
// window / brain.js / harness needs
|
||||
const brain = strip(fs.readFileSync("brain.js", "utf8"));
|
||||
const bUsed = [...new Set(brain.match(/(?<![.\w$])[A-Za-z_$][\w$]*/g) || [])].filter((n) => owner[n]);
|
||||
console.log(JSON.stringify({ report, brainUses: bUsed.map((n) => n + "@" + owner[n]) }, null, 1));
|
||||
if (!process.env.WRITE) process.exit(0);
|
||||
const rel = (from, to) => { const d = from.includes("/") ? "../" : "./"; return d + to; };
|
||||
let inline = "";
|
||||
for (const m of Object.values(mods)) {
|
||||
const ex = new Set(report[m.file].exports);
|
||||
const out = m.lines.map((ln, i) => { for (const [n, li] of m.decl) if (li === i && ex.has(n) && /^(const|let|async function|function)\b/.test(ln)) return "export " + ln; return ln; });
|
||||
const head = Object.entries(m.imports).map(([f, s]) => `import { ${[...s].sort().join(", ")} } from "${rel(m.file, f)}";`);
|
||||
const text = (head.length ? head.join("\n") + "\n" : "") + out.join("\n").replace(/\n+$/, "\n");
|
||||
if (m.file === "index.html") { inline = text; continue; }
|
||||
fs.mkdirSync(require("path").dirname(m.file), { recursive: true });
|
||||
fs.writeFileSync(m.file, text + (m.file === "app.js" ? GLOBALS : ""));
|
||||
}
|
||||
const tag = inline ? [`<script type="module">`, inline.replace(/\n$/, ""), GLOBALS.replace(/\n$/, ""), `</script>`] : [`<script type="module" src="app.js"></script>`];
|
||||
const html = src.slice(0, S - 1).concat(tag, src.slice(E));
|
||||
fs.writeFileSync("index.html", html.join("\n"));
|
||||
console.error("written");
|
||||
Reference in new issue
Block a user