mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-21 12:21:49 +00:00
73 bare `highway.x` references -> `window.highway.x`, across app.js and 10 other files. Provably a NO-OP today. It is the precondition for flipping highway.js to a module. ━━━ WHY THIS HAS TO LAND FIRST ━━━ highway.js is a CLASSIC script. Its top-level `const highway = createHighway()` therefore creates a GLOBAL LEXICAL BINDING — visible as a bare name to every other classic script AND to every ES module. 73 call sites quietly rely on that. The moment highway.js becomes a module, that binding is gone. `const` in a module is module-scoped, not global. Every one of those 73 sites becomes a ReferenceError, and the flip is impossible until they say what they mean. `window.highway = highway` is already set, to the same object, on the same line. So this is an identity rewrite — verified in the browser below. ━━━ THE REWRITE BIT ME THREE TIMES. REGEX IS NOT ENOUGH FOR THIS. ━━━ 1. A SHADOWED LOCAL. capabilities/note-detection.js does `const highway = window.highway`. Its 9 bare uses are LOCAL and already correct; a blind rewrite would have emitted `const window.highway = window.highway`. Excluded. 2. HALF-CONVERTED GUARDS — the dangerous one. Six sites read `typeof highway !== 'undefined' && highway && typeof highway.setTime === 'function'`. The regex converted the CONSEQUENT and left the TEST, which is WORSE than not touching them: after the flip `typeof highway` is 'undefined', so each guard is PERMANENTLY FALSE and the code behind it silently never runs. transport.js's was the seek->setTime sync: the chart clock would have quietly desynced after every seek, with nothing failing. All six now test window.highway. 3. TWO MORE BARE REFERENCES, found by Codex [P2] and confirmed by an AST scan: app.js:3114 and :3176 use `highway && typeof window.highway.getSections === 'function'`. My grep searched for `typeof highway`, not `highway &&`. After the flip these throw, the catch swallows it, and the editor silently falls back to a ±4s edit window and arrangement 0. Regex missed a shadow, a half-conversion, and two bare reads. The final check is an AST pass that resolves scopes and reports every `highway` identifier not bound locally. It now reports ZERO. VERIFIED. A/B against origin/main in two browsers, 15 probes, IDENTICAL, zero page errors: window.highway is the same object as the bare global, the whole API surface resolves, a real song plays, the chart clock advances, getPerf().drawMs > 0 — and `seek syncs chart` passes, which is the exact guard I nearly broke in (2). node 1045, pytest 2416, ESLint 0, Codex 0. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
261 lines
12 KiB
JavaScript
261 lines
12 KiB
JavaScript
// Verify static/app.js emits `loop:restart` exactly once when the A-B
|
|
// loop wraps, with the documented payload shape. Plugins (notedetect's
|
|
// drill-mode score capture) consume this contract.
|
|
//
|
|
// The test does not load the full app.js into a DOM — it extracts just
|
|
// the `startCountIn` function source via brace-matching and evaluates it
|
|
// in a vm sandbox with stubbed dependencies. This trades coverage of the
|
|
// surrounding script for isolation: a failure here points at the wrap
|
|
// path, not at unrelated DOM coupling.
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const vm = require('node:vm');
|
|
|
|
// startCountIn was carved out of app.js into its own module (R3a).
|
|
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'count-in.js');
|
|
|
|
// Pull a function body by declaration prefix (e.g. `async function startCountIn`)
|
|
// and brace-matching to the closing brace. Skips an optional `( ... )` param
|
|
// list so `startCountIn(opts = {})` and `startCountIn()` both match the same
|
|
// prefix. Brittle by design: rename/restructure fails loudly, not silently.
|
|
function extractFunction(src, signature) {
|
|
const start = src.indexOf(signature);
|
|
if (start === -1) throw new Error(`extractFunction: '${signature}' not found in app.js`);
|
|
let scan = start + signature.length;
|
|
if (src[scan] === '(') {
|
|
let parenDepth = 1;
|
|
scan++;
|
|
while (scan < src.length && parenDepth > 0) {
|
|
const ch = src[scan];
|
|
if (ch === '(') parenDepth++;
|
|
else if (ch === ')') parenDepth--;
|
|
scan++;
|
|
}
|
|
}
|
|
const openBrace = src.indexOf('{', scan);
|
|
let depth = 1;
|
|
let i = openBrace + 1;
|
|
while (i < src.length && depth > 0) {
|
|
const ch = src[i];
|
|
if (ch === '{') depth++;
|
|
else if (ch === '}') depth--;
|
|
i++;
|
|
}
|
|
if (depth !== 0) throw new Error(`extractFunction: unbalanced braces after '${signature}'`);
|
|
return src.slice(start, i);
|
|
}
|
|
|
|
function buildSandbox() {
|
|
const emitCalls = [];
|
|
const sandbox = {
|
|
// Globals the function reads/writes via closure. Declared as `var`
|
|
// in the eval prelude so they attach to the sandbox.
|
|
loopA: 10,
|
|
loopB: 20,
|
|
_countingIn: false,
|
|
// isPlaying / lastAudioTime moved onto the shared player-state container
|
|
// (static/js/player-state.js) so a carved module can WRITE them — an imported
|
|
// binding is read-only. Same values, same assertions, one indirection.
|
|
S: { isPlaying: false, lastAudioTime: 0 },
|
|
|
|
// Browser-ish globals.
|
|
performance: { now: () => Date.now() },
|
|
// requestAnimationFrame: skip to t >= 1 in one tick so the rewind
|
|
// animation completes synchronously and we reach the `_audioSeek`
|
|
// continuation immediately.
|
|
requestAnimationFrame(fn) {
|
|
// Fire with `now` far enough in the future that
|
|
// (now - rewindStart) / rewindDuration >= 1.
|
|
queueMicrotask(() => fn(Date.now() + 10_000));
|
|
},
|
|
// setTimeout: swallow. beginCount schedules ticks via setTimeout;
|
|
// we don't need them to fire — the emit happens before beginCount.
|
|
setTimeout: () => 0,
|
|
|
|
// Stubbed feedBack DOM dependencies.
|
|
audio: { pause() {} },
|
|
jucePlayer: { pause: () => Promise.resolve(), play: () => Promise.resolve(true) },
|
|
highway: { setTime() {}, getBPM: () => 120 },
|
|
|
|
// Stubbed app.js helpers.
|
|
// Resolve with the real shape `{ completed, from, to }` so
|
|
// startCountIn's loop-wrap callback sees completed=true and uses
|
|
// r.to for highway.setTime / lastAudioTime.
|
|
_audioSeek: (s) => Promise.resolve({ completed: true, from: 20, to: s }),
|
|
playClick: () => {},
|
|
showCountOverlay: () => {},
|
|
hideCountOverlay: () => {},
|
|
|
|
// Stubbed DOM access. Anything querying for a button just gets a
|
|
// permissive object that ignores writes.
|
|
document: {
|
|
getElementById: () => ({
|
|
textContent: '',
|
|
className: '',
|
|
classList: { add() {}, remove() {}, toggle() {} },
|
|
}),
|
|
},
|
|
|
|
// Spy: records every emit call so the test can assert.
|
|
window: {
|
|
feedBack: {
|
|
emit(event, detail) { emitCalls.push({ event, detail }); },
|
|
isPlaying: false,
|
|
},
|
|
_juceMode: false,
|
|
},
|
|
|
|
// Capture for assertions.
|
|
__emitCalls: emitCalls,
|
|
queueMicrotask,
|
|
};
|
|
// startCountIn was carved into static/js/count-in.js and now reaches back into
|
|
// app.js through the host seam (static/js/host.js). Point the seam at the SAME
|
|
// stubs the sandbox already had: the assertions below are unchanged, they just
|
|
// travel through the indirection the real code now uses.
|
|
sandbox.host = {
|
|
_audioSeek: (...a) => sandbox._audioSeek(...a),
|
|
setPlayButtonState: () => {},
|
|
_songEventPayload: () => ({}),
|
|
togglePlay: () => {},
|
|
jucePlayer: () => sandbox.jucePlayer,
|
|
};
|
|
vm.createContext(sandbox);
|
|
// highway.js is loaded as a CLASSIC script today, so its top-level `const highway`
|
|
// creates a global lexical binding that app.js and the modules could reach as a bare
|
|
// name. That binding disappears the moment highway.js becomes a module (R3c), so every
|
|
// consumer now says `window.highway` — the same object, explicitly. Mirror it here.
|
|
if (sandbox.window && sandbox.highway) sandbox.window.highway = sandbox.highway;
|
|
return sandbox;
|
|
}
|
|
|
|
test('loop:restart fires once when wrap path runs', async () => {
|
|
const src = fs.readFileSync(APP_JS, 'utf8').replace(/^export /gm, '');
|
|
const startCountInSrc = extractFunction(src, 'async function startCountIn');
|
|
|
|
// Sanity check: the change under test is present at all. Catches
|
|
// accidental revert before we even run the behavior assertion.
|
|
assert.match(
|
|
startCountInSrc,
|
|
/window\.feedBack\.emit\(\s*['"]loop:restart['"]/,
|
|
'startCountIn is missing the loop:restart emit',
|
|
);
|
|
|
|
const sandbox = buildSandbox();
|
|
// Re-declare the closure-scoped lets as vars so the function can read
|
|
// them from the sandbox global, then define the function in-context.
|
|
const prelude = `
|
|
var loopA = ${sandbox.loopA};
|
|
var loopB = ${sandbox.loopB};
|
|
var _countingIn = false;
|
|
var _countInGen = 0;
|
|
var _countInTimer = null;
|
|
var _countInRaf = 0;
|
|
var S = { isPlaying: false, lastAudioTime: 0 };
|
|
${startCountInSrc}
|
|
globalThis.__startCountIn = startCountIn;
|
|
`;
|
|
vm.runInContext(prelude, sandbox);
|
|
|
|
await sandbox.__startCountIn();
|
|
// Allow the queued requestAnimationFrame microtask + the _audioSeek
|
|
// promise chain to settle. Two awaits is enough: rAF microtask -> rewind
|
|
// completion -> _audioSeek().then() -> emit.
|
|
await new Promise((r) => setImmediate(r));
|
|
await new Promise((r) => setImmediate(r));
|
|
|
|
const restarts = sandbox.__emitCalls.filter((c) => c.event === 'loop:restart');
|
|
assert.equal(restarts.length, 1, `expected 1 loop:restart emit, got ${restarts.length}`);
|
|
// Field-wise assertion: deepStrictEqual fails across vm-context object
|
|
// realms because Object.prototype identities differ even when contents
|
|
// match. Compare values, not prototype graphs.
|
|
const detail = restarts[0].detail;
|
|
assert.equal(detail.loopA, 10);
|
|
assert.equal(detail.loopB, 20);
|
|
assert.equal(detail.time, 10);
|
|
assert.equal(Object.keys(detail).length, 3, `unexpected extra keys in detail: ${Object.keys(detail)}`);
|
|
});
|
|
|
|
test('loop:restart aborts when seek lands far from loopA (JUCE rollback)', async () => {
|
|
// Regression: if jucePlayer.seek rolls back (currentTime stays put),
|
|
// _audioSeek resolves with completed:true but r.to !== loopA. The
|
|
// wrap handler must abort instead of running beginCount on the wrong
|
|
// position and emitting a misleading loop:restart.
|
|
const src = fs.readFileSync(APP_JS, 'utf8').replace(/^export /gm, '');
|
|
const startCountInSrc = extractFunction(src, 'async function startCountIn');
|
|
|
|
const sandbox = buildSandbox();
|
|
// Override _audioSeek to mimic JUCE rollback: completed but to=from,
|
|
// far from the requested loopA (10).
|
|
sandbox._audioSeek = (s) => Promise.resolve({ completed: true, from: 20, to: 20 });
|
|
const prelude = `
|
|
var loopA = ${sandbox.loopA};
|
|
var loopB = ${sandbox.loopB};
|
|
var _countingIn = false;
|
|
var _countInGen = 0;
|
|
var _countInTimer = null;
|
|
var _countInRaf = 0;
|
|
var S = { isPlaying: false, lastAudioTime: 0 };
|
|
${startCountInSrc}
|
|
globalThis.__startCountIn = startCountIn;
|
|
globalThis.__getCountingIn = () => _countingIn;
|
|
`;
|
|
vm.runInContext(prelude, sandbox);
|
|
|
|
await sandbox.__startCountIn();
|
|
await new Promise((r) => setImmediate(r));
|
|
await new Promise((r) => setImmediate(r));
|
|
|
|
const restarts = sandbox.__emitCalls.filter((c) => c.event === 'loop:restart');
|
|
assert.equal(restarts.length, 0, 'rollback must not emit loop:restart');
|
|
assert.equal(sandbox.__getCountingIn(), false, '_countingIn must be cleared on abort');
|
|
});
|
|
|
|
test('count-in cancellation token bails delayed callbacks (rewindStep + tick)', () => {
|
|
// Source-level assertion: the gen-capture pattern is in place so
|
|
// teardown can interrupt an in-flight count-in. Behavioral simulation
|
|
// of timer cancellation is out of scope for the static extractor; this
|
|
// verifies the contract is wired into the source.
|
|
const src = fs.readFileSync(APP_JS, 'utf8').replace(/^export /gm, '');
|
|
const fn = extractFunction(src, 'async function startCountIn');
|
|
// Captures gen at entry
|
|
assert.match(fn, /const gen = _countInGen/, 'startCountIn must capture _countInGen at entry');
|
|
// Each delayed callback bails on mismatch
|
|
const guards = [...fn.matchAll(/if \(gen !== _countInGen\) return/g)];
|
|
assert.ok(guards.length >= 4, `expected ≥4 gen-mismatch bails, found ${guards.length}`);
|
|
// RAF and timer handles tracked so _cancelCountIn can cancel them
|
|
assert.match(fn, /_countInRaf = requestAnimationFrame/, 'rewindStep must store its RAF handle in _countInRaf');
|
|
assert.match(fn, /_countInTimer = setTimeout/, 'tick scheduling must store its timer in _countInTimer');
|
|
});
|
|
|
|
test('loop:restart fires after highway.setTime, before beginCount', () => {
|
|
// Source-order assertion on the A-B wrap path only. Section-practice
|
|
// `opts.immediate` also emits loop:restart but is a separate entry path;
|
|
// the wrap handler lives inside the `_audioSeek(loopA, 'loop-wrap')` then.
|
|
const src = fs.readFileSync(APP_JS, 'utf8').replace(/^export /gm, '');
|
|
const fn = extractFunction(src, 'async function startCountIn');
|
|
const wrapMarker = "_audioSeek(loopA, 'loop-wrap')";
|
|
const wrapStart = fn.indexOf(wrapMarker);
|
|
assert.ok(wrapStart !== -1, 'loop-wrap _audioSeek call not found in startCountIn');
|
|
const wrapSlice = fn.slice(wrapStart);
|
|
|
|
const setTimeMatches = [...wrapSlice.matchAll(/highway\.setTime\(\s*[^)]+\)/g)];
|
|
const setTimeIdx = setTimeMatches.length
|
|
? wrapStart + setTimeMatches[setTimeMatches.length - 1].index
|
|
: -1;
|
|
const emitRel = wrapSlice.search(/window\.feedBack\.emit\(\s*['"]loop:restart['"]/);
|
|
const emitIdx = emitRel === -1 ? -1 : wrapStart + emitRel;
|
|
const afterEmit = emitIdx === -1 ? '' : fn.slice(emitIdx);
|
|
const beginCallMatch = afterEmit.match(/(?<!function\s)\bbeginCount\s*\(/);
|
|
const beginCallIdx = beginCallMatch ? emitIdx + beginCallMatch.index : -1;
|
|
|
|
assert.ok(setTimeIdx !== -1, 'post-seek highway.setTime not found on wrap path');
|
|
assert.ok(emitIdx !== -1, 'loop:restart emit not found on wrap path');
|
|
assert.ok(beginCallIdx !== -1, 'beginCount() call not found after wrap emit');
|
|
assert.ok(setTimeIdx < emitIdx, 'wrap emit must come after highway.setTime');
|
|
assert.ok(emitIdx < beginCallIdx, 'wrap emit must come before beginCount()');
|
|
});
|