Merge pull request #2147 from heygen-com/07-10-fix_core_enforce_strict_runtime_safety

fix(core): enforce strict runtime safety
This commit is contained in:
James Russo
2026-07-11 21:56:47 -07:00
committed by GitHub
24 changed files with 348 additions and 116 deletions
+3
View File
@@ -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:
+7 -5
View File
@@ -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"
},
@@ -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"}`];
@@ -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",
+46 -30
View File
@@ -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 = `
<html>
<body>
<div id="root" data-composition-id="comp-1" data-width="1920" data-height="1080" data-start="0">
<div id="stage"></div>
</div>
<script src="https://cdn.gsap.com/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
window.__timelines["comp-1"] = tl;
</script>
</body>
</html>`;
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 = `
<html>
<body>
@@ -31,7 +46,7 @@ function testDetectsMissingCompositionHostId() {
</html>
`;
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 = `
<html>
<body>
@@ -57,7 +72,7 @@ function testDetectsOverlappingGsapTweens() {
</html>
`;
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();
+1 -1
View File
@@ -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) {
+2 -2
View File
@@ -100,7 +100,7 @@ export function createWaapiAdapter(): RuntimeDeterministicAdapter {
value: original,
configurable: true,
});
const wrappedAnimate = function (...args: Parameters<Element["animate"]>) {
const wrappedAnimate = function (this: Element, ...args: Parameters<Element["animate"]>) {
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) {
@@ -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;
@@ -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);
+1 -1
View File
@@ -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);
@@ -366,14 +366,15 @@ async function mountCompositionContent(params: {
details: Record<string, string | number | boolean | null | string[]>;
}) => void;
}): Promise<void> {
let innerRoot: Element | null = null;
let innerRoot: HTMLElement | null = null;
if (params.authoredCompositionId) {
const candidateRoots = Array.from(
params.sourceNode.querySelectorAll<Element>("[data-composition-id]"),
params.sourceNode.querySelectorAll<HTMLElement>("[data-composition-id]"),
);
innerRoot =
candidateRoots.find(
(candidate) =>
candidate instanceof HTMLElement &&
candidate.getAttribute("data-composition-id") === params.authoredCompositionId,
) ?? null;
}
+26 -4
View File
@@ -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 = `
<div data-composition-id="root" data-start="0" data-duration="5" data-width="1920" data-height="1080"></div>
`;
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 <header>/<footer> in a column)
+31 -26
View File
@@ -44,6 +44,7 @@ import type {
} from "./types";
import type { PlayerAPI } from "../core.types";
import { swallow } from "./diagnostics";
import { shouldAttemptPeriodicTimelineBind } from "./timelineRebindPolicy";
const AUTHORED_DURATION_ATTR = "data-hf-authored-duration";
const AUTHORED_END_ATTR = "data-hf-authored-end";
@@ -186,7 +187,7 @@ export function initSandboxRuntimeModular(): void {
} else {
for (let i = 0; i < arr.length; i++) normalized[`tl-${i}`] = arr[i];
}
(window as Record<string, unknown>).__timelines = normalized;
(window as unknown as Record<string, unknown>).__timelines = normalized;
}
// Agents sometimes omit data-start on the root composition element. The
@@ -295,7 +296,6 @@ export function initSandboxRuntimeModular(): void {
const MIN_VALID_TIMELINE_DURATION_SECONDS = 1 / 60;
const TIMELINE_FLOOR_COVERAGE_RATIO = 0.75;
const PLAY_REBIND_HOLD_SECONDS = 2;
const METADATA_REBIND_MIN_DURATION_GAIN_SECONDS = 0.05;
const METADATA_REBIND_DEBOUNCE_MS = 100;
const MAX_DIAGNOSTIC_MESSAGE_LENGTH = 240;
@@ -776,7 +776,9 @@ export function initSandboxRuntimeModular(): void {
!!entry[1] && typeof entry[1].play === "function" && typeof entry[1].pause === "function",
);
if (usable.length !== 1) return { timeline: null };
const [soleId, soleTimeline] = usable[0];
const sole = usable[0];
if (!sole) return { timeline: null };
const [soleId, soleTimeline] = sole;
return {
timeline: soleTimeline,
selectedTimelineIds: [soleId],
@@ -1223,7 +1225,7 @@ export function initSandboxRuntimeModular(): void {
// reapplyPositionEditsAfterSeek to un-bake it. Call the apply hook
// directly here as well, since the wrapper may not be installed yet
// during initial rebind (timing race on first load / soft reload).
const applyFn = (window as Record<string, unknown>).__hfStudioManualEditsApply;
const applyFn = (window as unknown as Record<string, unknown>).__hfStudioManualEditsApply;
if (typeof applyFn === "function") applyFn();
// SDK moveElement edits (data-hf-edit-base-x/y markers) render as a
@@ -1996,8 +1998,10 @@ export function initSandboxRuntimeModular(): void {
// handler. Identity is stable as long as the inputs are stable (each
// adapter is expected to return the same promise on repeat calls while
// its work is in flight).
const firstPromise = promises[0];
if (!firstPromise) return true;
const combined: PromiseLike<unknown> =
promises.length === 1 ? promises[0] : Promise.all(promises);
promises.length === 1 ? firstPromise : Promise.all(promises);
if (combined !== trackedAdapterReadyPromise) {
trackedAdapterReadyPromise = combined;
trackedAdapterReadySettled = false;
@@ -2512,10 +2516,10 @@ export function initSandboxRuntimeModular(): void {
}
for (const child of children) {
if (!isObjectRecord(child) || !isObjectRecord(child.vars)) continue;
const hasCallback = GSAP_CALLBACK_NAMES.some(
(name) => typeof child.vars[name] === "function",
);
if (!isObjectRecord(child)) continue;
const vars = child.vars;
if (!isObjectRecord(vars)) continue;
const hasCallback = GSAP_CALLBACK_NAMES.some((name) => typeof vars[name] === "function");
if (!hasCallback) continue;
const totalDuration = readGsapDuration(child, "totalDuration");
@@ -2629,24 +2633,25 @@ export function initSandboxRuntimeModular(): void {
transportTickCount += 1;
// Slower operations: timeline binding (~every 60 frames / ~1s at 60fps)
if (transportTickCount % 60 === 0) {
const shouldHoldRebind =
clock.isPlaying() &&
state.capturedTimeline != null &&
clock.now() < PLAY_REBIND_HOLD_SECONDS;
if (!shouldHoldRebind) {
const prevTimeline = state.capturedTimeline;
if (bindRootTimelineIfAvailable()) {
if (state.capturedTimeline && !player._timeline) {
player._timeline = state.capturedTimeline;
}
if (state.capturedTimeline && state.capturedTimeline !== prevTimeline) {
state.capturedTimeline.pause();
}
const dur = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
if (dur > 0) clock.setDuration(dur);
postTimeline();
if (
shouldAttemptPeriodicTimelineBind({
tick: transportTickCount,
isPlaying: clock.isPlaying(),
hasCapturedTimeline: state.capturedTimeline != null,
currentTimeSeconds: clock.now(),
})
) {
const prevTimeline = state.capturedTimeline;
if (bindRootTimelineIfAvailable()) {
if (state.capturedTimeline && !player._timeline) {
player._timeline = state.capturedTimeline;
}
if (state.capturedTimeline && state.capturedTimeline !== prevTimeline) {
state.capturedTimeline.pause();
}
const dur = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
if (dur > 0) clock.setDuration(dur);
postTimeline();
}
}
if (transportTickCount % 20 === 0) {
@@ -1,3 +1,5 @@
import type { RuntimeTimelineLike } from "./types";
/**
* Shared volume-automation utilities used by both the renderer (offline PCM
* baking in audioVolumeEnvelope.ts) and the preview runtime (per-tick gain
@@ -125,10 +127,7 @@ export function probeElementVolumeKeyframes(
return hasAutomation ? keyframes : null;
}
export interface RuntimeTimelineRef {
totalTime?: ((t?: number, suppressEvents?: boolean) => unknown) | undefined;
seek?: ((t?: number, suppressEvents?: boolean) => unknown) | undefined;
}
export type RuntimeTimelineRef = Partial<Pick<RuntimeTimelineLike, "totalTime" | "seek">>;
/**
* Probe a media element and, if volume automation is detected, store the
+2 -4
View File
@@ -142,8 +142,7 @@ export function createPickerModule(deps: PickerModuleDeps): PickerModule {
if (blocksPickerAtPoint(raw[0] ?? null)) return [];
const dedupe: Record<string, true> = {};
const candidates: Element[] = [];
for (let i = 0; i < raw.length; i += 1) {
const node = raw[i];
for (const [i, node] of raw.entries()) {
if (!isPickableElement(node)) continue;
const key = `${node.tagName}::${(node as HTMLElement).id || ""}::${i}`;
if (dedupe[key]) continue;
@@ -157,8 +156,7 @@ export function createPickerModule(deps: PickerModuleDeps): PickerModule {
function extractElementInfo(el: Element): RuntimePickerElementInfo {
const rect = el.getBoundingClientRect();
const dataAttributes: Record<string, string> = {};
for (let i = 0; i < el.attributes.length; i += 1) {
const attr = el.attributes[i];
for (const attr of Array.from(el.attributes)) {
if (attr.name.startsWith("data-")) {
dataAttributes[attr.name] = attr.value;
}
+16 -10
View File
@@ -11,11 +11,13 @@ function createMockTimeline(opts?: { time?: number; duration?: number }): Runtim
pause: vi.fn(() => {
state.paused = true;
}),
seek: vi.fn((t: number) => {
state.time = t;
seek: vi.fn((t?: number) => {
if (t !== undefined) state.time = t;
return state.time;
}),
totalTime: vi.fn((t: number) => {
state.time = t;
totalTime: vi.fn((t?: number) => {
if (t !== undefined) state.time = t;
return state.time;
}),
time: vi.fn(() => state.time),
duration: vi.fn(() => state.duration),
@@ -64,11 +66,13 @@ function createNestedTimelineHarness() {
pause: vi.fn(() => {
state.paused = true;
}),
seek: vi.fn((t: number) => {
state.time = t;
seek: vi.fn((t?: number) => {
if (t !== undefined) state.time = t;
return state.time;
}),
totalTime: vi.fn((t: number) => {
state.time = t;
totalTime: vi.fn((t?: number) => {
if (t !== undefined) state.time = t;
return state.time;
}),
time: vi.fn(() => state.time),
duration: vi.fn(() => duration),
@@ -95,14 +99,16 @@ function createNestedTimelineHarness() {
pause: vi.fn(() => {
masterState.paused = true;
}),
seek: vi.fn((t: number) => {
seek: vi.fn((t?: number) => {
if (t === undefined) return masterState.time;
masterState.time = t;
for (const child of children) {
if (child.state.paused) continue;
child.state.time = Math.max(0, Math.min(t - child.start, child.duration));
}
}),
totalTime: vi.fn((t: number) => {
totalTime: vi.fn((t?: number) => {
if (t === undefined) return masterState.time;
masterState.time = t;
for (const child of children) {
if (child.state.paused) continue;
+1 -2
View File
@@ -385,8 +385,7 @@ export function collectRuntimeTimelinePayload(params: {
),
);
let maxEnd = 0;
for (let i = 0; i < nodes.length; i += 1) {
const node = nodes[i];
for (const [i, node] of nodes.entries()) {
if (node === root) continue;
if (["SCRIPT", "STYLE", "LINK", "META", "TEMPLATE", "NOSCRIPT"].includes(node.tagName))
continue;
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import {
PLAY_REBIND_HOLD_SECONDS,
TIMELINE_REBIND_INTERVAL_FRAMES,
shouldAttemptPeriodicTimelineBind,
} from "./timelineRebindPolicy";
describe("shouldAttemptPeriodicTimelineBind", () => {
it("only checks for replacements at the periodic boundary", () => {
expect(
shouldAttemptPeriodicTimelineBind({
tick: TIMELINE_REBIND_INTERVAL_FRAMES - 1,
isPlaying: false,
hasCapturedTimeline: true,
currentTimeSeconds: 0,
}),
).toBe(false);
expect(
shouldAttemptPeriodicTimelineBind({
tick: TIMELINE_REBIND_INTERVAL_FRAMES,
isPlaying: false,
hasCapturedTimeline: true,
currentTimeSeconds: 0,
}),
).toBe(true);
});
it("holds a captured timeline during the first playback seconds", () => {
expect(
shouldAttemptPeriodicTimelineBind({
tick: TIMELINE_REBIND_INTERVAL_FRAMES,
isPlaying: true,
hasCapturedTimeline: true,
currentTimeSeconds: PLAY_REBIND_HOLD_SECONDS - 0.001,
}),
).toBe(false);
expect(
shouldAttemptPeriodicTimelineBind({
tick: TIMELINE_REBIND_INTERVAL_FRAMES,
isPlaying: true,
hasCapturedTimeline: true,
currentTimeSeconds: PLAY_REBIND_HOLD_SECONDS,
}),
).toBe(true);
});
it("does not hold when no timeline has bound yet", () => {
expect(
shouldAttemptPeriodicTimelineBind({
tick: TIMELINE_REBIND_INTERVAL_FRAMES,
isPlaying: true,
hasCapturedTimeline: false,
currentTimeSeconds: 0,
}),
).toBe(true);
});
});
@@ -0,0 +1,22 @@
export const TIMELINE_REBIND_INTERVAL_FRAMES = 60;
export const PLAY_REBIND_HOLD_SECONDS = 2;
export function shouldAttemptPeriodicTimelineBind(input: {
tick: number;
isPlaying: boolean;
hasCapturedTimeline: boolean;
currentTimeSeconds: number;
}): boolean {
if (
!Number.isInteger(input.tick) ||
input.tick <= 0 ||
input.tick % TIMELINE_REBIND_INTERVAL_FRAMES !== 0
) {
return false;
}
return !(
input.isPlaying &&
input.hasCapturedTimeline &&
input.currentTimeSeconds < PLAY_REBIND_HOLD_SECONDS
);
}
+17 -2
View File
@@ -228,17 +228,32 @@ export type RuntimeSeekOptions = {
suppressEvents?: boolean;
};
export type RuntimeTimelineChildLike = {
targets?: () => unknown[];
vars?: unknown;
startTime?: () => number;
duration?: () => number;
parent?: RuntimeTimelineChildLike;
};
export type RuntimeTimelineLike = {
play: () => void;
pause: () => void;
seek: (timeSeconds: number, suppressEvents?: boolean) => void;
totalTime?: (timeSeconds: number, suppressEvents?: boolean) => void;
seek: (timeSeconds?: number, suppressEvents?: boolean) => unknown;
totalTime?: (timeSeconds?: number, suppressEvents?: boolean) => unknown;
progress?: (value?: number, suppressEvents?: boolean) => unknown;
time: () => number;
duration: () => number;
add: (timeline: RuntimeTimelineLike, startAtSeconds: number) => void;
paused: (paused?: boolean) => void;
timeScale?: (rate: number) => void;
set: (target: RuntimeGsapSetTarget, vars: RuntimeGsapSetVars, atSeconds?: number) => void;
getChildren?: (
nested?: boolean,
tweens?: boolean,
timelines?: boolean,
ignoreBeforeTime?: number,
) => RuntimeTimelineChildLike[];
};
export type RuntimeDeterministicAdapter = {
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"files": [],
"include": ["src/runtime/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
-1
View File
@@ -12,7 +12,6 @@ export default defineConfig({
"src/runtime/types.ts",
"src/runtime/window.d.ts",
"src/runtime/entry.ts",
"src/runtime/init.ts",
"src/runtime/README.md",
],
thresholds: {
@@ -9,6 +9,7 @@ export const PRODUCER_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".
// or local sockets. Keep the list explicit so a filename-only rename does not
// make Git/fallow re-audit thousands of unchanged test lines as new code.
const INTEGRATION_TEST_FILES = new Set([
"src/services/coreRuntimeBrowser.test.ts",
"src/services/deterministicFonts-systemCapture.test.ts",
"src/services/distributed/assemble.test.ts",
"src/services/distributed/chunkBoundary.test.ts",
@@ -0,0 +1,93 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import puppeteer, { type Browser, type Page } from "puppeteer";
const RUNTIME_PATH = resolve(import.meta.dirname, "../../../core/dist/hyperframe.runtime.iife.js");
describe("core runtime browser contract", () => {
let browser: Browser;
let page: Page;
beforeAll(async () => {
browser = await puppeteer.launch({
headless: true,
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});
page = await browser.newPage();
await page.setContent(`<!doctype html>
<style>
@keyframes slide { from { transform: translateX(0); } to { transform: translateX(100px); } }
#box { animation: slide 2s linear both; }
</style>
<div data-composition-id="root" data-start="0" data-duration="2" data-width="320" data-height="180">
<div id="box"></div>
</div>`);
await page.addScriptTag({ content: readFileSync(RUNTIME_PATH, "utf8") });
await page.waitForFunction(
() =>
(window as unknown as { __playerReady?: boolean }).__playerReady === true &&
(window as unknown as { __renderReady?: boolean }).__renderReady === true,
);
}, 30_000);
afterAll(async () => {
await browser?.close();
});
it("initializes the public player contract and seeks the CSS adapter", async () => {
const result = await page.evaluate(() => {
const runtimeWindow = window as unknown as {
__player?: {
play?: () => void;
pause?: () => void;
renderSeek?: (timeSeconds: number) => void;
getDuration?: () => number;
isPlaying?: () => boolean;
};
};
const player = runtimeWindow.__player;
player?.renderSeek?.(1);
const animation = document.getElementById("box")?.getAnimations()[0];
return {
hasPlay: typeof player?.play === "function",
hasPause: typeof player?.pause === "function",
hasRenderSeek: typeof player?.renderSeek === "function",
duration: player?.getDuration?.(),
animationTime: Number(animation?.currentTime),
};
});
expect(result).toEqual({
hasPlay: true,
hasPause: true,
hasRenderSeek: true,
duration: 2,
animationTime: 1000,
});
});
it("removes the control bridge during teardown", async () => {
const result = await page.evaluate(async () => {
const runtimeWindow = window as unknown as {
__hfRuntimeTeardown?: (() => void) | null;
__player?: { isPlaying?: () => boolean };
};
const hadTeardown = typeof runtimeWindow.__hfRuntimeTeardown === "function";
runtimeWindow.__hfRuntimeTeardown?.();
window.dispatchEvent(
new MessageEvent("message", {
data: { source: "hf-parent", type: "control", action: "play" },
}),
);
await new Promise((resolveFrame) => requestAnimationFrame(() => resolveFrame(undefined)));
return {
hadTeardown,
teardownCleared: runtimeWindow.__hfRuntimeTeardown === null,
isPlaying: runtimeWindow.__player?.isPlaying?.(),
};
});
expect(result).toEqual({ hadTeardown: true, teardownCleared: true, isPlaying: false });
});
});