mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 02:36:10 +00:00
* fix(slideshow): address split-PR review findings on #1585 Genuinely-open findings from the #1580/#1590/#1591/#1592 reviews (the rest were already fixed on this branch: CSP handlers, manifest version, UUID ids, float keys, presenter 1s-timer): core (#1580): - isManifest rejects a non-object/array manifest (e.g. [42,null]) explicitly - resolveSlideshow flags duplicate slideSequence ids instead of silent overwrite player (#1590): - present() window.open uses noopener,noreferrer (audience syncs via channel) - BroadcastChannel name is per-deck (keyed on pathname) to avoid same-origin cross-talk between decks - add observedAttributes + attributeChangedCallback so runtime sound/mode toggles re-render studio (#1591/#1592): - persistSlideshowManifest no-op gate (skip write when HTML is unchanged) - surface persist failures (console.error) instead of silent .catch(()=>{}) - confirm before deleting a branch sequence (data-loss + dangling hotspots) + tests for the collision + non-object-manifest rejection. 20 core / 106 player / 53 studio pass; tsc/lint/fmt/fallow clean; deck still renders. * fix(slideshow): finish remaining split-PR review findings The larger items from the #1580/#1590/#1591/#1592 reviews (the rest landed in #1585): core (#1580): - dedup isSceneLikeCompositionId — shared slideshow/sceneId.ts, used by both the lint rule and the runtime scene-window computation (no more mirror-and-drift) player (#1590 / #1592): - onKey: when multiple decks share a page, drop the unfocused-convenience so a key drives only the focused deck - slow-iframe recovery: if the scene timeline posts after the wait times out (empty scenes), re-init once so sceneId slides resolve instead of being dropped studio (#1591): - persistSlideshowManifest validates the built island round-trips before writing - reorderBranchSlide helper + BranchTree up/down controls (parallel to main-line reorder), with a branch-position indicator + tests for reorderBranchSlide. core 228 / player 106 / studio (panel) 46 pass; tsc/lint/fmt/fallow clean. * fix(player,cli): use fileURLToPath for path resolution (Windows CI) new URL(...).pathname yields a leading-slash drive path ("/D:/...") on Windows, which broke: - packages/player/vitest.config.ts — the @hyperframes/core/slideshow alias resolved to a nonexistent path, failing the player slideshow tests on the Windows render-verification CI (passed on macOS/Linux where pathname is clean) - packages/cli/src/utils/compositionServer.ts helperDir — same bug in the play/present bundle-path resolution fileURLToPath converts file:// URLs to correct OS paths on all platforms. Player slideshow tests pass; present serves + resolves bundles. * fix(producer): fileURLToPath for the renders dir (Windows) DEFAULT_RENDERS_DIR used new URL(import.meta.url).pathname, which is "/D:/..." on Windows and resolves to a bogus path — affects the Windows render pipeline. Last of the .pathname -> fileURLToPath fixes (repo-wide src sweep now clean).
200 lines
7.9 KiB
TypeScript
200 lines
7.9 KiB
TypeScript
// packages/core/src/slideshow/parseSlideshow.test.ts
|
|
import { describe, it, expect } from "vitest";
|
|
import { parseSlideshowManifest, resolveSlideshow } from "./parseSlideshow";
|
|
|
|
const ISLAND = `<!doctype html><html><body>
|
|
<script type="application/hyperframes-slideshow+json">
|
|
{ "slides": [
|
|
{ "sceneId": "a", "fragments": [2.0, 1.0], "hotspots": [{ "id": "h1", "label": "Why?", "target": "deep" }] },
|
|
{ "sceneId": "b" }
|
|
],
|
|
"slideSequences": [ { "id": "deep", "label": "Deep dive", "slides": [ { "sceneId": "c" } ] } ]
|
|
}
|
|
</script>
|
|
</body></html>`;
|
|
|
|
const SCENES = [
|
|
{ id: "a", start: 0, duration: 5 },
|
|
{ id: "b", start: 5, duration: 5 },
|
|
{ id: "c", start: 10, duration: 3 },
|
|
];
|
|
|
|
describe("parseSlideshowManifest", () => {
|
|
it("returns null when no island present", () => {
|
|
expect(parseSlideshowManifest("<html></html>")).toBeNull();
|
|
});
|
|
|
|
it("parses the island JSON", () => {
|
|
const m = parseSlideshowManifest(ISLAND);
|
|
expect(m?.slides.length).toBe(2);
|
|
expect(m?.slideSequences?.[0].id).toBe("deep");
|
|
});
|
|
|
|
it("throws when slideSequences is present but not an array", () => {
|
|
const html = `<script type="application/hyperframes-slideshow+json">
|
|
{ "slides": [{ "sceneId": "a" }], "slideSequences": {} }
|
|
</script>`;
|
|
expect(() => parseSlideshowManifest(html)).toThrow();
|
|
});
|
|
|
|
it("rejects a non-object manifest (e.g. a JSON array)", () => {
|
|
const html = `<script type="application/hyperframes-slideshow+json">[42, null]</script>`;
|
|
expect(() => parseSlideshowManifest(html)).toThrow();
|
|
});
|
|
|
|
it("throws when a slide entry is malformed (sceneId not a string)", () => {
|
|
const html = `<script type="application/hyperframes-slideshow+json">
|
|
{ "slides": [{ "sceneId": 42 }] }
|
|
</script>`;
|
|
expect(() => parseSlideshowManifest(html)).toThrow();
|
|
});
|
|
});
|
|
|
|
describe("resolveSlideshow", () => {
|
|
it("resolves scene time ranges and sorts fragments", () => {
|
|
const m = parseSlideshowManifest(ISLAND);
|
|
if (!m) throw new Error("manifest expected");
|
|
const { resolved, errors } = resolveSlideshow(m, SCENES);
|
|
expect(errors).toEqual([]);
|
|
expect(resolved.slides[0].start).toBe(0);
|
|
expect(resolved.slides[0].end).toBe(5);
|
|
expect(resolved.slides[0].fragments).toEqual([1.0, 2.0]); // sorted
|
|
expect(resolved.sequences.deep.slides[0].start).toBe(10);
|
|
});
|
|
|
|
it("honors explicit startTime/endTime overrides", () => {
|
|
const m: import("./slideshow.types").SlideshowManifest = {
|
|
slides: [{ sceneId: "a", startTime: 1, endTime: 4 }],
|
|
};
|
|
const { resolved } = resolveSlideshow(m, SCENES);
|
|
expect(resolved.slides[0].start).toBe(1);
|
|
expect(resolved.slides[0].end).toBe(4);
|
|
});
|
|
|
|
it("reports an error for an unresolved sceneId", () => {
|
|
const m: import("./slideshow.types").SlideshowManifest = {
|
|
slides: [{ sceneId: "missing" }],
|
|
};
|
|
const { errors } = resolveSlideshow(m, SCENES);
|
|
expect(errors.some((e) => e.includes("missing"))).toBe(true);
|
|
});
|
|
|
|
it("flags duplicate slideSequence ids instead of silently overwriting", () => {
|
|
const m: import("./slideshow.types").SlideshowManifest = {
|
|
slides: [{ sceneId: "a" }],
|
|
slideSequences: [
|
|
{ id: "dup", label: "First", slides: [{ sceneId: "c" }] },
|
|
{ id: "dup", label: "Second", slides: [{ sceneId: "c" }] },
|
|
],
|
|
};
|
|
const { errors } = resolveSlideshow(m, SCENES);
|
|
expect(errors.some((e) => e.includes("duplicate slideSequence id"))).toBe(true);
|
|
});
|
|
|
|
it("reports an error for a fragment outside the slide range", () => {
|
|
const m: import("./slideshow.types").SlideshowManifest = {
|
|
slides: [{ sceneId: "a", fragments: [99] }],
|
|
};
|
|
const { errors } = resolveSlideshow(m, SCENES);
|
|
expect(errors.some((e) => e.includes("fragment"))).toBe(true);
|
|
});
|
|
|
|
it("reports an error for a hotspot target with no sequence", () => {
|
|
const m: import("./slideshow.types").SlideshowManifest = {
|
|
slides: [{ sceneId: "a", hotspots: [{ id: "h", label: "x", target: "nope" }] }],
|
|
};
|
|
const { errors } = resolveSlideshow(m, SCENES);
|
|
expect(errors.some((e) => e.includes("nope"))).toBe(true);
|
|
});
|
|
|
|
it("reports an error for overlapping main-line slides", () => {
|
|
const m: import("./slideshow.types").SlideshowManifest = {
|
|
slides: [
|
|
{ sceneId: "a", startTime: 0, endTime: 6 },
|
|
{ sceneId: "b", startTime: 5, endTime: 10 },
|
|
],
|
|
};
|
|
const { errors } = resolveSlideshow(m, SCENES);
|
|
expect(errors.some((e) => e.includes("overlap"))).toBe(true);
|
|
});
|
|
|
|
// Partial-override cases
|
|
it("fills missing endTime from scene when only startTime is provided and scene exists", () => {
|
|
const m: import("./slideshow.types").SlideshowManifest = {
|
|
slides: [{ sceneId: "a", startTime: 2 }],
|
|
};
|
|
const { resolved, errors } = resolveSlideshow(m, SCENES);
|
|
expect(errors).toEqual([]);
|
|
expect(resolved.slides[0].start).toBe(2);
|
|
expect(resolved.slides[0].end).toBe(5); // scene a: start=0, duration=5
|
|
});
|
|
|
|
it("fills missing startTime from scene when only endTime is provided and scene exists", () => {
|
|
const m: import("./slideshow.types").SlideshowManifest = {
|
|
slides: [{ sceneId: "a", endTime: 3 }],
|
|
};
|
|
const { resolved, errors } = resolveSlideshow(m, SCENES);
|
|
expect(errors).toEqual([]);
|
|
expect(resolved.slides[0].start).toBe(0); // scene a: start=0
|
|
expect(resolved.slides[0].end).toBe(3);
|
|
});
|
|
|
|
it("reports a clear error when only startTime is provided but scene is absent", () => {
|
|
const m: import("./slideshow.types").SlideshowManifest = {
|
|
slides: [{ sceneId: "x", startTime: 2 }],
|
|
};
|
|
const { errors } = resolveSlideshow(m, SCENES);
|
|
expect(errors.length).toBeGreaterThan(0);
|
|
// Must mention the missing bound (endTime), not the misleading "unresolved sceneId"
|
|
expect(errors.some((e) => e.includes("endTime"))).toBe(true);
|
|
expect(errors.some((e) => e.includes("unresolved sceneId"))).toBe(false);
|
|
});
|
|
|
|
it("reports a clear error when only endTime is provided but scene is absent", () => {
|
|
const m: import("./slideshow.types").SlideshowManifest = {
|
|
slides: [{ sceneId: "x", endTime: 5 }],
|
|
};
|
|
const { errors } = resolveSlideshow(m, SCENES);
|
|
expect(errors.length).toBeGreaterThan(0);
|
|
// Must mention the missing bound (startTime), not the misleading "unresolved sceneId"
|
|
expect(errors.some((e) => e.includes("startTime"))).toBe(true);
|
|
expect(errors.some((e) => e.includes("unresolved sceneId"))).toBe(false);
|
|
});
|
|
|
|
it("reports an error for an inverted explicit range (endTime <= startTime)", () => {
|
|
const m: import("./slideshow.types").SlideshowManifest = {
|
|
slides: [{ sceneId: "a", startTime: 5, endTime: 2 }],
|
|
};
|
|
const { errors } = resolveSlideshow(m, SCENES);
|
|
expect(errors.some((e) => e.includes("endTime") && e.includes("startTime"))).toBe(true);
|
|
});
|
|
|
|
it("de-duplicates fragments before resolving", () => {
|
|
const m: import("./slideshow.types").SlideshowManifest = {
|
|
slides: [{ sceneId: "a", fragments: [2, 1, 2, 1, 3] }],
|
|
};
|
|
const { resolved, errors } = resolveSlideshow(m, SCENES);
|
|
expect(errors).toEqual([]);
|
|
expect(resolved.slides[0].fragments).toEqual([1, 2, 3]);
|
|
});
|
|
|
|
it("reports an error for a hotspot targeting an empty sequence", () => {
|
|
const m: import("./slideshow.types").SlideshowManifest = {
|
|
slides: [{ sceneId: "a", hotspots: [{ id: "h", label: "x", target: "empty" }] }],
|
|
slideSequences: [{ id: "empty", label: "Empty", slides: [] }],
|
|
};
|
|
const { errors } = resolveSlideshow(m, SCENES);
|
|
expect(errors.some((e) => e.includes("empty sequence"))).toBe(true);
|
|
});
|
|
|
|
it("full override with no scene produces no error", () => {
|
|
const m: import("./slideshow.types").SlideshowManifest = {
|
|
slides: [{ sceneId: "noexist", startTime: 1, endTime: 4 }],
|
|
};
|
|
const { resolved, errors } = resolveSlideshow(m, SCENES);
|
|
expect(errors).toEqual([]);
|
|
expect(resolved.slides[0].start).toBe(1);
|
|
expect(resolved.slides[0].end).toBe(4);
|
|
});
|
|
});
|