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 /