From 8113884d987c8031130dd7bc8d6df6b5544141e7 Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Thu, 8 Oct 2026 16:43:39 +0800 Subject: [PATCH] fix(slideshow): validate nested hotspot entries --- packages/lint/src/rules/slideshow.test.ts | 16 ++++++ .../src/slideshow/parseSlideshow.test.ts | 50 +++++++++++++++++++ .../parsers/src/slideshow/parseSlideshow.ts | 34 +++++++++++-- .../slideshow/hyperframes-slideshow.test.ts | 8 +++ 4 files changed, 105 insertions(+), 3 deletions(-) diff --git a/packages/lint/src/rules/slideshow.test.ts b/packages/lint/src/rules/slideshow.test.ts index 8b7ed694649..4c801e66bf0 100644 --- a/packages/lint/src/rules/slideshow.test.ts +++ b/packages/lint/src/rules/slideshow.test.ts @@ -51,6 +51,22 @@ describe("slideshow lint rule", () => { expect(findings[0]!.code).toBe("slideshow_invalid"); }); + it.each([null, {}, { id: "h1", label: 42, target: "deep" }])( + "reports malformed hotspot %j as a structure finding without crashing lint", + async (hotspot) => { + const html = `
+
+ +
`; + expect(await findSlideshow(html)).toEqual([ + expect.objectContaining({ code: "slideshow_invalid", severity: "error" }), + ]); + }, + ); + it("passes when sceneId resolves to a data-composition-id element (no .clip[id])", async () => { const html = `
diff --git a/packages/parsers/src/slideshow/parseSlideshow.test.ts b/packages/parsers/src/slideshow/parseSlideshow.test.ts index 8251a164057..429621d8964 100644 --- a/packages/parsers/src/slideshow/parseSlideshow.test.ts +++ b/packages/parsers/src/slideshow/parseSlideshow.test.ts @@ -48,6 +48,56 @@ describe("parseSlideshowManifest", () => { `; expect(() => parseSlideshowManifest(html)).toThrow(); }); + + it.each([ + null, + 42, + "branch", + [], + {}, + { id: 1, label: "Go", target: "deep" }, + { id: "h1", label: null, target: "deep" }, + { id: "h1", label: "Go", target: [] }, + { id: "h1", label: "Go", target: "deep", region: null }, + { id: "h1", label: "Go", target: "deep", region: { x: 10, y: 20 } }, + { id: "h1", label: "Go", target: "deep", region: { x: "10", y: 20, w: 30, h: 40 } }, + ])("rejects malformed hotspot %j in main-line and branch slides", (hotspot) => { + for (const manifest of [ + { slides: [{ sceneId: "a", hotspots: [hotspot] }] }, + { + slides: [{ sceneId: "a" }], + slideSequences: [ + { id: "deep", label: "Deep", slides: [{ sceneId: "c", hotspots: [hotspot] }] }, + ], + }, + ]) { + const html = ``; + expect(() => parseSlideshowManifest(html)).toThrow("not a valid SlideshowManifest"); + } + }); + + it("preserves valid hotspot regions and optional hotspot arrays", () => { + const manifest = { + slides: [ + { + sceneId: "a", + hotspots: [ + { id: "h1", label: "Go", target: "deep", region: { x: 0, y: 12.5, w: 100, h: 20 } }, + ], + }, + ], + slideSequences: [{ id: "deep", label: "Deep", slides: [{ sceneId: "c", hotspots: [] }] }], + }; + const html = ``; + expect(parseSlideshowManifest(html)).toEqual(manifest); + }); + + it("rejects a hotspot region whose JSON number overflows to infinity", () => { + const html = ``; + expect(() => parseSlideshowManifest(html)).toThrow("not a valid SlideshowManifest"); + }); }); describe("resolveSlideshow", () => { diff --git a/packages/parsers/src/slideshow/parseSlideshow.ts b/packages/parsers/src/slideshow/parseSlideshow.ts index a05537ce507..906ed3c6c82 100644 --- a/packages/parsers/src/slideshow/parseSlideshow.ts +++ b/packages/parsers/src/slideshow/parseSlideshow.ts @@ -2,6 +2,7 @@ import type { SlideshowManifest, SlideRef, + SlideHotspot, ResolvedSlide, ResolvedSlideshow, ResolvedSlideSequence, @@ -52,12 +53,39 @@ function isOptionalBoolean(v: unknown): v is boolean | undefined { return v === undefined || typeof v === "boolean"; } +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function isHotspotRegion(v: unknown): boolean { + return ( + isRecord(v) && + [v["x"], v["y"], v["w"], v["h"]].every( + (coordinate) => typeof coordinate === "number" && Number.isFinite(coordinate), + ) + ); +} + +function isSlideHotspot(v: unknown): v is SlideHotspot { + return ( + isRecord(v) && + typeof v["id"] === "string" && + typeof v["label"] === "string" && + typeof v["target"] === "string" && + (v["region"] === undefined || isHotspotRegion(v["region"])) + ); +} + function isSlideRef(v: unknown): v is SlideRef { - if (typeof v !== "object" || v === null) return false; - const r = v as Record; + if (!isRecord(v)) return false; + const r = v; if (typeof r["sceneId"] !== "string") return false; if (!isOptionalNumberArray(r["fragments"])) return false; - if (r["hotspots"] !== undefined && !Array.isArray(r["hotspots"])) return false; + if ( + r["hotspots"] !== undefined && + (!Array.isArray(r["hotspots"]) || !r["hotspots"].every(isSlideHotspot)) + ) + return false; if (!isOptionalBoolean(r["autoplay"])) return false; return true; } diff --git a/packages/player/src/slideshow/hyperframes-slideshow.test.ts b/packages/player/src/slideshow/hyperframes-slideshow.test.ts index b2d3efe67a1..0ed739a6991 100644 --- a/packages/player/src/slideshow/hyperframes-slideshow.test.ts +++ b/packages/player/src/slideshow/hyperframes-slideshow.test.ts @@ -1750,6 +1750,14 @@ describe("slideshow parts (pure)", () => { expect(parts.player).toBe(root.querySelector("hyperframes-player")); expect(parts.manifest.slides.map((s) => s.sceneId)).toEqual(["intro"]); }); + + it("classifies a malformed hotspot as a malformed island before slideshow initialization", () => { + const root = subtree(` + `); + expect(locateSlideshowParts(root)).toEqual({ kind: "incomplete", reason: "malformed-island" }); + }); }); // ---------------------------------------------------------------------------