From 0af07a07c5368b857fde0cd8cc9c8d788a39fab9 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 10 Jul 2026 10:00:21 -0700 Subject: [PATCH] fix(core): enforce strict runtime safety --- .github/workflows/ci.yml | 3 + packages/core/package.json | 12 ++- .../core/scripts/check-hyperframe-static.ts | 3 +- .../scripts/lint-runtime-preview-guards.ts | 21 +---- .../core/scripts/test-hyperframe-linter.ts | 76 +++++++++------ packages/core/src/runtime/adapters/css.ts | 2 +- packages/core/src/runtime/adapters/waapi.ts | 4 +- .../core/src/runtime/applyVariableBindings.ts | 3 +- packages/core/src/runtime/captionOverrides.ts | 2 +- packages/core/src/runtime/colorGrading.ts | 2 +- .../core/src/runtime/compositionLoader.ts | 5 +- packages/core/src/runtime/init.test.ts | 30 +++++- packages/core/src/runtime/init.ts | 57 ++++++------ .../core/src/runtime/mediaVolumeEnvelope.ts | 7 +- packages/core/src/runtime/picker.ts | 6 +- packages/core/src/runtime/player.test.ts | 26 ++++-- packages/core/src/runtime/timeline.ts | 3 +- .../src/runtime/timelineRebindPolicy.test.ts | 57 ++++++++++++ .../core/src/runtime/timelineRebindPolicy.ts | 22 +++++ packages/core/src/runtime/types.ts | 19 +++- packages/core/tsconfig.runtime.json | 9 ++ packages/core/vitest.config.ts | 1 - .../producer/scripts/test-classification.mjs | 1 + .../src/services/coreRuntimeBrowser.test.ts | 93 +++++++++++++++++++ 24 files changed, 348 insertions(+), 116 deletions(-) create mode 100644 packages/core/src/runtime/timelineRebindPolicy.test.ts create mode 100644 packages/core/src/runtime/timelineRebindPolicy.ts create mode 100644 packages/core/tsconfig.runtime.json create mode 100644 packages/producer/src/services/coreRuntimeBrowser.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc38804287..c8f7acb03b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -372,6 +372,9 @@ jobs: node-version: 22 - uses: ./.github/actions/prepare-ffmpeg-bin - run: bun install --frozen-lockfile + # Runtime coverage now imports core modules that consume workspace + # subpaths. Build their dist exports before Vitest resolves them. + - run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build - run: bun run --filter @hyperframes/core test:hyperframe-runtime-ci studio-load-smoke: diff --git a/packages/core/package.json b/packages/core/package.json index a5bfd89cee..239ba6b209 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -402,8 +402,10 @@ "test": "bun run check:position-edits-render && vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "typecheck": "tsc --noEmit", - "lint:runtime-preview-guards": "tsx scripts/lint-runtime-preview-guards.ts", + "test:runtime-coverage": "vitest run --coverage src/runtime", + "typecheck": "tsc --noEmit && bun run typecheck:runtime", + "typecheck:runtime": "tsc --noEmit -p tsconfig.runtime.json", + "lint:runtime-preview-guards": "bun scripts/lint-runtime-preview-guards.ts", "build:hyperframes-runtime": "tsx scripts/build-hyperframes-runtime-artifact.ts", "build:position-edits-render": "tsx scripts/build-position-edits-render.ts", "check:position-edits-render": "bun run build:position-edits-render && git diff --exit-code -- src/generated/position-edits-render-inline.ts", @@ -415,9 +417,9 @@ "test:hyperframe-runtime-duration-guards": "tsx scripts/test-hyperframe-runtime-duration-guards.ts", "test:hyperframe-runtime-parity": "tsx scripts/test-hyperframe-runtime-parity.ts", "test:hyperframe-runtime-security": "tsx scripts/test-hyperframe-runtime-security.ts", - "test:hyperframe-linter": "tsx scripts/test-hyperframe-linter.ts", - "test:hyperframe-runtime-ci": "bun run build:hyperframes-runtime && bun run test:hyperframe-runtime-contract && bun run test:hyperframe-runtime-behavior && bun run test:hyperframe-runtime-seek && bun run test:hyperframe-runtime-duration-guards && bun run test:hyperframe-runtime-parity && bun run test:hyperframe-runtime-security", - "check:hyperframe-html": "tsx scripts/check-hyperframe-static.ts", + "test:hyperframe-linter": "bun scripts/test-hyperframe-linter.ts", + "test:hyperframe-runtime-ci": "bun run typecheck:runtime && bun run lint:runtime-preview-guards && bun run build:hyperframes-runtime && bun run test:hyperframe-runtime-contract && bun run test:hyperframe-runtime-behavior && bun run test:hyperframe-runtime-seek && bun run test:hyperframe-runtime-duration-guards && bun run test:hyperframe-runtime-parity && bun run test:hyperframe-runtime-security && bun run test:runtime-coverage && bun run test:hyperframe-linter", + "check:hyperframe-html": "bun scripts/check-hyperframe-static.ts", "debug:timeline": "tsx scripts/debug-timeline.ts", "prepublishOnly": "echo skip" }, diff --git a/packages/core/scripts/check-hyperframe-static.ts b/packages/core/scripts/check-hyperframe-static.ts index e61e1ec754..ecf17b5be5 100644 --- a/packages/core/scripts/check-hyperframe-static.ts +++ b/packages/core/scripts/check-hyperframe-static.ts @@ -1,7 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import { lintHyperframeHtml } from "../src/lint/hyperframeLinter"; -import type { HyperframeLintResult } from "../src/lint/types"; +import { lintHyperframeHtml, type HyperframeLintResult } from "@hyperframes/lint"; function formatCounts(result: HyperframeLintResult): string { const parts = [`${result.warningCount} warning${result.warningCount === 1 ? "" : "s"}`]; diff --git a/packages/core/scripts/lint-runtime-preview-guards.ts b/packages/core/scripts/lint-runtime-preview-guards.ts index fdfc481557..bebba4227c 100644 --- a/packages/core/scripts/lint-runtime-preview-guards.ts +++ b/packages/core/scripts/lint-runtime-preview-guards.ts @@ -13,6 +13,9 @@ type GuardCheckResult = { failed: GuardSpec[]; }; +// Keep only temporary source-shape guards that do not yet have behavioral +// coverage. Timeline replacement and early-play rebinding are exercised by +// init.test.ts and timelineRebindPolicy.test.ts instead of regexes. const GUARD_SPECS: GuardSpec[] = [ { id: "external_compositions_gate", @@ -20,12 +23,6 @@ const GUARD_SPECS: GuardSpec[] = [ filePath: "src/runtime/init.ts", pattern: /if\s*\(\s*!externalCompositionsReady\s*\)\s*return\s+false;/, }, - { - id: "usable_timeline_gate", - description: "Skip rebinding when current timeline is already usable", - filePath: "src/runtime/init.ts", - pattern: /if\s*\(\s*currentTimeline\s*&&\s*currentTimelineUsable\s*\)\s*return\s+false;/, - }, { id: "child_timeline_activation", description: "Force root child timelines active before composition binding", @@ -39,18 +36,6 @@ const GUARD_SPECS: GuardSpec[] = [ pattern: /if\s*\(\s*!isUsableTimelineDuration\(rootDurationSeconds\)\s*&&\s*rootChildCandidates\.length\s*>\s*0\s*\)/, }, - { - id: "loop_guard_rebind", - description: "Enable loop guard based timeline rebinding", - filePath: "src/runtime/init.ts", - pattern: /if\s*\(\s*rebindTimelineFromResolution\(resolution,\s*"loop_guard"\)\s*\)/, - }, - { - id: "early_play_rebind_hold", - description: "Hold rebinding during first playback seconds", - filePath: "src/runtime/init.ts", - pattern: /shouldHoldRebindDuringEarlyPlay/, - }, { id: "external_script_ordering", description: "Inject external composition scripts with deterministic ordering", diff --git a/packages/core/scripts/test-hyperframe-linter.ts b/packages/core/scripts/test-hyperframe-linter.ts index bdb8a5027c..a6ce3559ba 100644 --- a/packages/core/scripts/test-hyperframe-linter.ts +++ b/packages/core/scripts/test-hyperframe-linter.ts @@ -1,21 +1,36 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import fs from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import path from "node:path"; -import { lintHyperframeHtml } from "../src/lint/hyperframeLinter"; +import { fileURLToPath } from "node:url"; +import { lintHyperframeHtml } from "@hyperframes/lint"; -const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), ".."); +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const VALID_COMPOSITION = ` + + +
+
+
+ + + +`; -function testCleanFixturePasses() { - const fixturePath = path.join(ROOT, "src/tests/chat-project-9/index.html"); - const html = fs.readFileSync(fixturePath, "utf8"); - const result = lintHyperframeHtml(html, { filePath: fixturePath }); +async function testCleanFixturePasses() { + const result = await lintHyperframeHtml(VALID_COMPOSITION, { filePath: "valid.html" }); - assert.equal(result.ok, true, "chat-project-9 should pass without lint errors"); - assert.equal(result.errorCount, 0, "chat-project-9 should have zero lint errors"); + assert.equal(result.ok, true, "valid composition should pass without lint errors"); + assert.equal(result.errorCount, 0, "valid composition should have zero lint errors"); } -function testDetectsMissingCompositionHostId() { +async function testDetectsMissingCompositionHostId() { const html = ` @@ -31,7 +46,7 @@ function testDetectsMissingCompositionHostId() { `; - const result = lintHyperframeHtml(html); + const result = await lintHyperframeHtml(html); const codes = result.findings.map((finding) => finding.code); assert.equal(result.ok, false, "missing composition ids should fail lint"); @@ -39,7 +54,7 @@ function testDetectsMissingCompositionHostId() { assert.ok(codes.includes("host_missing_composition_id")); } -function testDetectsOverlappingGsapTweens() { +async function testDetectsOverlappingGsapTweens() { const html = ` @@ -57,7 +72,7 @@ function testDetectsOverlappingGsapTweens() { `; - const result = lintHyperframeHtml(html); + const result = await lintHyperframeHtml(html); const overlapFinding = result.findings.find( (finding) => finding.code === "overlapping_gsap_tweens", ); @@ -67,29 +82,30 @@ function testDetectsOverlappingGsapTweens() { } function testCliJsonOutput() { - const fixturePath = path.join(ROOT, "src/tests/chat-project-9/index.html"); - const tsxBin = path.join(ROOT, "node_modules/.bin/tsx"); - const stdout = execFileSync( - tsxBin, - ["scripts/check-hyperframe-static.ts", "--json", fixturePath], - { + const tempDir = mkdtempSync(path.join(tmpdir(), "hf-core-lint-script-")); + try { + const fixturePath = path.join(tempDir, "index.html"); + writeFileSync(fixturePath, VALID_COMPOSITION, "utf8"); + const stdout = execFileSync("bun", ["run", "check:hyperframe-html", "--json", fixturePath], { cwd: ROOT, encoding: "utf8", - }, - ); - const payload = JSON.parse(stdout); + }); + const payload = JSON.parse(stdout); - assert.equal(payload.ok, true); - assert.equal(typeof payload.errorCount, "number"); - assert.ok(Array.isArray(payload.findings)); + assert.equal(payload.ok, true); + assert.equal(typeof payload.errorCount, "number"); + assert.ok(Array.isArray(payload.findings)); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } } -function main() { - testCleanFixturePasses(); - testDetectsMissingCompositionHostId(); - testDetectsOverlappingGsapTweens(); +async function main() { + await testCleanFixturePasses(); + await testDetectsMissingCompositionHostId(); + await testDetectsOverlappingGsapTweens(); testCliJsonOutput(); console.log("hyperframe linter tests passed"); } -main(); +await main(); diff --git a/packages/core/src/runtime/adapters/css.ts b/packages/core/src/runtime/adapters/css.ts index b884e928a4..51b560abfa 100644 --- a/packages/core/src/runtime/adapters/css.ts +++ b/packages/core/src/runtime/adapters/css.ts @@ -37,7 +37,7 @@ export function createCssAdapter(params?: { animation: Animation, startSeconds: number, ): { endSeconds?: number; unbounded?: true } => { - let timing: { endTime?: number | string } | null = null; + let timing: ComputedEffectTiming | null = null; try { timing = animation.effect?.getComputedTiming?.() ?? null; } catch (err) { diff --git a/packages/core/src/runtime/adapters/waapi.ts b/packages/core/src/runtime/adapters/waapi.ts index f17af71540..0ed5b1fa97 100644 --- a/packages/core/src/runtime/adapters/waapi.ts +++ b/packages/core/src/runtime/adapters/waapi.ts @@ -100,7 +100,7 @@ export function createWaapiAdapter(): RuntimeDeterministicAdapter { value: original, configurable: true, }); - const wrappedAnimate = function (...args: Parameters) { + const wrappedAnimate = function (this: Element, ...args: Parameters) { const animation = original.apply(this, args); trackAnimation(animation, lastSeekTimeMs); return animation; @@ -126,7 +126,7 @@ export function createWaapiAdapter(): RuntimeDeterministicAdapter { const inferAnimationEndSeconds = ( animation: Animation, ): { endSeconds?: number; unbounded?: true } => { - let timing: { endTime?: number | string } | null = null; + let timing: ComputedEffectTiming | null = null; try { timing = animation.effect?.getComputedTiming?.() ?? null; } catch (err) { diff --git a/packages/core/src/runtime/applyVariableBindings.ts b/packages/core/src/runtime/applyVariableBindings.ts index 79a37211a7..7b704f9338 100644 --- a/packages/core/src/runtime/applyVariableBindings.ts +++ b/packages/core/src/runtime/applyVariableBindings.ts @@ -60,7 +60,8 @@ function isSafeMediaUrl(url: string): boolean { const normalized = url.replace(/[\u0000-\u0020]/g, ""); const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(normalized); if (!scheme) return true; - const proto = scheme[1].toLowerCase(); + const proto = scheme[1]?.toLowerCase(); + if (!proto) return false; if (proto === "https" || proto === "http" || proto === "blob") return true; if (proto === "data") return /^data:image\//i.test(normalized); return false; diff --git a/packages/core/src/runtime/captionOverrides.ts b/packages/core/src/runtime/captionOverrides.ts index bb115fec4d..d90c049227 100644 --- a/packages/core/src/runtime/captionOverrides.ts +++ b/packages/core/src/runtime/captionOverrides.ts @@ -135,7 +135,7 @@ export function applyCaptionOverrides(): void { // Use the first tween's color as the dim baseline — if no tweens, // fall back to computed style. - const dimBaseline = colorTweens.length > 0 ? String(colorTweens[0].vars.color) : ""; + const dimBaseline = colorTweens[0] ? String(colorTweens[0].vars.color) : ""; for (const tw of colorTweens) { const tweenColor = String(tw.vars.color); diff --git a/packages/core/src/runtime/colorGrading.ts b/packages/core/src/runtime/colorGrading.ts index 8ec35806b6..4fd00faf0b 100644 --- a/packages/core/src/runtime/colorGrading.ts +++ b/packages/core/src/runtime/colorGrading.ts @@ -542,7 +542,7 @@ function createProgram( return program; } -function createTexture(gl: WebGLRenderingContext, filter = gl.LINEAR): WebGLTexture | null { +function createTexture(gl: WebGLRenderingContext, filter: number = gl.LINEAR): WebGLTexture | null { const texture = gl.createTexture(); if (!texture) return null; gl.bindTexture(gl.TEXTURE_2D, texture); diff --git a/packages/core/src/runtime/compositionLoader.ts b/packages/core/src/runtime/compositionLoader.ts index 3b6264dd52..c86236c236 100644 --- a/packages/core/src/runtime/compositionLoader.ts +++ b/packages/core/src/runtime/compositionLoader.ts @@ -366,14 +366,15 @@ async function mountCompositionContent(params: { details: Record; }) => void; }): Promise { - let innerRoot: Element | null = null; + let innerRoot: HTMLElement | null = null; if (params.authoredCompositionId) { const candidateRoots = Array.from( - params.sourceNode.querySelectorAll("[data-composition-id]"), + params.sourceNode.querySelectorAll("[data-composition-id]"), ); innerRoot = candidateRoots.find( (candidate) => + candidate instanceof HTMLElement && candidate.getAttribute("data-composition-id") === params.authoredCompositionId, ) ?? null; } diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 3f66788c1f..378a6a9bb4 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -12,11 +12,13 @@ function createMockTimeline(duration: number): RuntimeTimelineLike { pause: () => { state.paused = true; }, - seek: (time: number) => { - state.time = time; + seek: (time?: number) => { + if (time !== undefined) state.time = time; + return state.time; }, - totalTime: (time: number) => { - state.time = time; + totalTime: (time?: number) => { + if (time !== undefined) state.time = time; + return state.time; }, time: () => state.time, duration: () => state.duration, @@ -1756,6 +1758,26 @@ describe("initSandboxRuntimeModular", () => { expect(seekTimes.length).toBeGreaterThan(beforeResume); }); + it("keeps a usable bound timeline when the registry entry is replaced", () => { + const raf = createManualRaf(); + vi.spyOn(performance, "now").mockImplementation(() => raf.now()); + window.requestAnimationFrame = raf.requestAnimationFrame as typeof window.requestAnimationFrame; + window.cancelAnimationFrame = raf.cancelAnimationFrame as typeof window.cancelAnimationFrame; + + document.body.innerHTML = ` +
+ `; + const originalTimeline = createMockTimeline(5); + window.__timelines = { root: originalTimeline }; + initSandboxRuntimeModular(); + + const replacementTimeline = createMockTimeline(8); + window.__timelines.root = replacementTimeline; + for (let frame = 0; frame < 60; frame += 1) raf.step(16); + + expect(window.__player?.getDuration()).toBe(5); + }); + // applyClipLayout force-absolutizes authored root-level timed clips so they // stack as overlays. But in Studio/preview the runtime also stamps `data-start` // onto ID'd / GSAP-targeted *flow* children (a
/