Files
hyperframes/packages/inspector/tests/timelineCompositions.ts
Xuanru Li 45825faa5d fix(inspector): grep timeline windows, continuation, candidates and timeline text from the Canary dogfood (#5595)
* fix(inspector): report nested hosts' authored window, one location per source line

The runtime strips data-duration/data-end from nested composition hosts after
boot and keeps them as data-hf-authored-*. Timeline inspection built its
resolver without authored attrs, so a host with data-duration="4" reported
its GSAP timeline length (3.72s) instead.

Timeline text and events[].sources listed the same file:line once per mapped
snippet (creation, caller, dependency), so a one-line script printed one
location up to nine times.

* refactor(inspector): carry the dedupe reason in the helper's name

* fix(inspector): end continuation on a page that fits no snippet; fill budget with candidates

* fix(inspector): timeline groups by component, names attr values, eases, film end

* fix(studio-server): stamp hf ids into a file without reformatting it

* fix(inspector): find an element's tag before its served hf id reaches the source

* fix(inspector): live element map shows authored classes and text, not the playhead's

* fix(inspector): collapse script-built text so the element map ignores the playhead

Class names stay in source order, a runtime-stripped id is shown as id="…"
rather than a selector, and a grouped row names which children it covers.

* fix(inspector): keep paging, stamping, and the live map still on the reported edges

A snippet larger than the page ended the walk, so later snippets could not
be reached. Raw text ended at a longer tag name, which could stamp an id
inside a script. An empty or mixed source element showed live text, and an
unknown duration read as a film that ends at 0s.

* fix(inspector): mint ids for an empty hf id and keep an unquoted class

An empty data-hf-id was treated as already assigned, so stamping left it
blank while the older stamper minted one. An unquoted class was read as no
class, so the live map dropped it.

* fix(inspector): narrow markup before reading a child tag

skipMarkup returns a boolean, so the build still sees Markup where a tag
is required. Narrow on the kind first so the inspector typechecks.

* fix: quiet check false positives and name the ancestor of an id-less target

Endpoint contact, disjoint fromTo pairs, and a negative z-index under a
stacking-context parent were warnings with bad advice. Layout and contrast
blamed a dot grid, a circular avatar's panel, and a visibility override.
A faint texture veil is no longer the click target.

* fix(lint): drop a history sentence and six gsap comments

The comments check treats "load-bearing" as history, and gsap.ts added six
comment lines. The names already carry what those lines said.

* fix(parsers): ignore an empty id written inside another attribute

The empty-id scan matched the first data-hf-id="" in the tag, including one
inside a quoted title. It now skips quoted text, so the real attribute is minted.

* fix(lint): keep real covers and a negative z-index under a card

A gradient scale-down treated every fill like a radial dot, so a panel
that starts transparent stopped counting as a cover. A stacking context
on any ancestor cleared a negative z-index, and a faint wrapper made
images inside it unpickable.

* fix: keep an emoji from shifting a stamped id

The quote mask replaced one emoji with one space, so the id landed before
the closing quote. A radial cover that turns opaque after a clear stop
stays fully painted.

* fix(lint): score a letter-only dot as a dot, not a color name

namesAColor saw #fff and rgba as words, so those dot grids counted as a
full cover. Hex and rgb colors are stripped before that word check.
2026-10-14 09:16:20 +00:00

131 lines
5.7 KiB
TypeScript

import { mkdtempSync, writeFileSync, copyFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import assert from "node:assert/strict";
import { Inspector } from "../src/service";
const vendor = process.argv[2];
if (!vendor) throw new Error("Pass gsap.min.js");
const project = mkdtempSync(join(tmpdir(), "hf-composition-timeline-"));
copyFileSync(vendor, join(project, "gsap.js"));
writeFileSync(
join(project, "index.html"),
`<html><head><script src="gsap.js"></script></head><body>
<div data-composition-id="main" data-duration="30" data-width="1920" data-height="1080">
<div data-composition-id="card" data-start="2.39" data-duration="4">
<div id="icon"></div><div id="word"></div><div id="paused"></div><i class="cand"></i><i class="cand"></i>
</div>
<div data-composition-id="fast" data-start="10" data-duration="5" data-media-start="1" data-playback-rate="2">
<div id="fast"></div><div id="trimmed"></div><div id="partial"></div>
</div>
<div data-composition-id="ramp" data-start="20" data-duration="5" data-automation='{"version":1,"lanes":[{"target":"rate","points":[{"t":0,"v":1},{"t":2,"v":3}]}]}'>
<div id="ramp"></div>
</div>
<div id="grain"></div>
<div data-composition-id="over" data-start="0" data-duration="2"><div id="over"></div></div>
<div data-composition-id="loop" data-start="26" data-duration="3">
<div id="loopa"></div><div id="loopb"></div>
</div>
</div>
<script>(()=>{const loop=gsap.timeline({paused:true});["#loopa","#loopb"].forEach(s=>loop.to(s,{x:1,duration:1},0));window.__timelines={loop:loop};})();</script>
<script>
const main = gsap.timeline({paused:true});
const card = gsap.timeline({paused:true});
card.to('#icon',{scale:1,duration:.32},0);
card.to('#word',{opacity:1,duration:.1},.35);
const paused = gsap.timeline({paused:true});
paused.to('#paused',{x:100,duration:1},0);
card.add(paused,0);
main.add(card,99);
const fast = gsap.timeline({paused:true});
fast.to('#fast',{x:100,duration:2},2);
fast.to('#trimmed',{x:100,duration:.25},0);
fast.to('#partial',{opacity:1,duration:1},.5);
main.add(fast,99);
const ramp = gsap.timeline({paused:true});
ramp.to('#ramp',{x:100,duration:3.641},0);
main.add(window.__timelines.loop,99);
const over = gsap.timeline({paused:true});
over.to('#over',{x:1,duration:1},0);
main.add(over,99);
main.to('#grain',{x:5,duration:.1,repeat:40},0);
window.__timelines = {...window.__timelines, main:main, over:over, card:card, fast:fast, ramp:ramp};
</script></body></html>`,
);
const inspector = new Inspector(project);
try {
const intervals = async (selector: string) =>
(await inspector.timeline({ component: { selector } })).events
.filter((e) => e.kind === "animation")
.map((e) => [e.start, e.end]);
const close = (actual: number[][], expected: number[][]) => {
assert.equal(actual.length, expected.length);
actual.forEach((pair, n) =>
pair.forEach((value, k) =>
assert(Math.abs(value - expected[n][k]) < 0.001, `${value} != ${expected[n][k]}`),
),
);
};
close(await intervals("#icon"), [[2.39, 2.71]]);
close(await intervals("#word"), [[2.74, 2.84]]);
close(await intervals("#fast"), [[10.5, 11.5]]);
close(await intervals("#trimmed"), []);
close(await intervals("#partial"), [[10, 10.25]]);
close(await intervals("#ramp"), [[20, 22]]);
// The runtime strips a nested host's data-duration once loaded; its item keeps the authored window.
const hostItem = async (composition: string) =>
(
await inspector.timeline({
component: { selector: `[data-composition-id="${composition}"]` },
})
).events
.filter((e) => e.kind === "item" && e.targets[0]?.composition === composition)
.map((e) => [e.start, e.end]);
close(await hostItem("card"), [[2.39, 6.39]]);
close(await hostItem("fast"), [[10, 15]]);
const loop = await inspector.timeline({ component: { selector: "#loopa" } });
const loopAnimation = loop.events.find((e) => e.kind === "animation")!;
assert.equal(loopAnimation.sources.length, 1);
assert.equal(
loop.text
.split("\n")
.filter((line) => line.trim() === `index.html:${loopAnimation.sources[0].line}`).length,
1,
);
const paused = await inspector.timeline({ component: { selector: "#paused" } });
assert.equal(paused.events.length, 0);
assert(paused.warnings.some((w) => w.includes("Paused or zero-speed")));
assert.equal(
(await inspector.timeline({ boundary: 2.71, component: { selector: "#icon" } })).events[0]
.relation,
"ends",
);
assert.equal(
(await inspector.timeline({ at: 2.71, component: { selector: "#icon" } })).events.length,
0,
);
const whole = await inspector.timeline({});
const headings = whole.text.split("\n").filter((line) => /^\S.* \[hfi:/.test(line));
assert.equal(new Set(headings).size, headings.length, headings.join("\n"));
const grainRelations = (await inspector.timeline({ range: [0, 2] })).text
.split("\n")
.filter((line) => line.startsWith("Relation:") && line.includes(" x;"));
assert.equal(grainRelations.length, 1);
assert.match(
(await inspector.timeline({ range: [40, 50] })).text,
/past the end: the film is 30s long/,
);
const candidates = await inspector.timeline({ component: { selector: ".cand" } });
assert.notEqual(candidates.status, "found");
assert.equal(
candidates.text.split("\n").filter((line) => / \.cand i in card$/.test(line)).length,
2,
);
console.log(
"PASS managed paused compositions, host offsets, playback offsets/rates/ramps, paused children, boundary/at, authored host windows, one location per source line, one heading per component, one relation per repeating action, past-the-end ranges, named candidates",
);
} finally {
await inspector.dispose();
rmSync(project, { recursive: true, force: true });
}