Files
hyperframes/packages/inspector/tests/mapInspection.test.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

122 lines
4.3 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { mapInspection } from "../src/mapInspection";
import { Sources } from "../src/sources";
import type { ElementRef, RuntimeResponse, Snippet } from "../src/types";
const element = (n: number): ElementRef => ({
ref: `hfi:g:${n}`,
tag: "i",
id: "",
hfId: null,
classes: ["w-dot"],
composition: "s03-weeks",
});
function project(css: string) {
const dir = mkdtempSync(join(tmpdir(), "inspect-map-"));
writeFileSync(
join(dir, "index.html"),
`<html><head><style>\n${css}\n</style></head><body></body></html>`,
);
return dir;
}
function inspect(dir: string, candidates: number, request: Parameters<typeof mapInspection>[1]) {
const sources = new Sources(dir);
const raw: RuntimeResponse = {
results: [
{
target: { selector: ".w-dot" },
status: "found",
candidates: Array.from({ length: candidates }, (_, n) => element(n)),
animations: [],
elements: [],
css: sources.cssRules.map((rule) => rule.id),
truncated: false,
},
],
recorded: 0,
recordLimitReached: false,
hooked: true,
};
return mapInspection(raw, request, {
sources,
map: () => [],
catalog: new Map<string, Snippet>(),
generation: "g",
sourceVersion: "v",
reused: false,
start: performance.now(),
});
}
describe("components report budget", () => {
it("never answers a page that fits no snippet with a continuation to that same page", () => {
const big = `.big{${"color:red;".repeat(300)}}`;
const dir = project(big);
try {
const page = inspect(dir, 1, { targets: [{ selector: ".w-dot" }], maxBytes: 2048 });
expect(page.snippets).toHaveLength(0);
expect(page.nextOffset).toBeNull();
expect(page.omitted).toHaveLength(1);
expect(page.omitted[0].bytes).toBeGreaterThan(2048);
const fetched = inspect(dir, 1, {
targets: [],
snippets: page.omitted.map((o) => o.id),
maxBytes: 12000,
});
expect(fetched.snippets.map((s) => s.code.trim())).toEqual([big]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("returns every candidate the budget holds instead of five", () => {
const dir = project(".w-dot{color:red}");
try {
const report = inspect(dir, 25, { targets: [{ selector: ".w-dot" }] });
expect(report.results[0].candidates).toHaveLength(25);
expect(report.results[0].candidatesOmitted).toBe(0);
expect(report.snippets).toHaveLength(1);
const tight = inspect(dir, 25, { targets: [{ selector: ".w-dot" }], maxBytes: 4096 });
expect(Buffer.byteLength(JSON.stringify(tight))).toBeLessThanOrEqual(4096);
expect(tight.results[0].candidates.length).toBeGreaterThanOrEqual(5);
expect(tight.results[0].candidates.length + tight.results[0].candidatesOmitted).toBe(25);
expect(tight.snippets).toHaveLength(1);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("continues past a snippet that does not fit so later snippets stay reachable", () => {
const big = `.big{${"color:red;".repeat(300)}}`;
const small = Array.from({ length: 20 }, (_, n) => `.s${n}{color:red}`);
const dir = project([big, ...small].join("\n\n"));
try {
const first = inspect(dir, 1, { targets: [{ selector: ".w-dot" }], maxBytes: 2048 });
expect(first.snippets).toHaveLength(0);
expect(first.nextOffset).toBe(1);
expect(first.omitted[0]?.bytes).toBeGreaterThan(2048);
const seen = new Set<string>();
let offset: number | null = first.nextOffset;
for (let guard = 0; offset !== null && guard < 25; guard++) {
const page = inspect(dir, 1, {
targets: [{ selector: ".w-dot" }],
maxBytes: 2048,
offset,
});
for (const snippet of page.snippets) seen.add(snippet.code.trim());
expect(page.nextOffset === null || page.nextOffset > offset).toBe(true);
offset = page.nextOffset;
}
expect(offset).toBeNull();
expect([...seen].sort()).toEqual([...small].sort());
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});