diff --git a/packages/inspector/README.md b/packages/inspector/README.md index d11af9a2161..a1e24b0a420 100644 --- a/packages/inspector/README.md +++ b/packages/inspector/README.md @@ -42,7 +42,7 @@ The package ships the capability, not agent tools: a harness defines its own too ## Output -The default JSON is capped at 12,000 UTF-8 bytes, including metadata. `snippets` contain file, start/end line, kinds, associated input target indices, **unmodified full source lines**, and whether that exact block occurs only once in the file. No pretty-printing or ellipses are inserted into code. Adjacent/overlapping blocks merge across targets. +The default JSON is capped at 12,000 UTF-8 bytes, including metadata. Each snippet's `id` names its file and its start and end lines (`file:start-end`); a snippet also carries its kinds, **unmodified full source lines**, and whether that exact block occurs only once in the file. When a query names more than one target, `targets` gives the input target indices it serves. No pretty-printing or ellipses are inserted into code. Adjacent/overlapping blocks merge across targets. ```json { @@ -51,9 +51,6 @@ The default JSON is capped at 12,000 UTF-8 bytes, including metadata. `snippets` "snippets": [ { "id": "scene.html:20-24", - "file": "scene.html", - "startLine": 20, - "endLine": 24, "kind": ["animation creation"], "targets": [0, 1], "code": "the exact original source lines, including whitespace", @@ -65,7 +62,7 @@ The default JSON is capped at 12,000 UTF-8 bytes, including metadata. `snippets` This illustration omits metadata. Actual files in the implementation report show the full output. -Shared helpers appear once with all **queried** targets using them. `coTargets` names observed additional DOM targets of the same tween; it does not claim to enumerate all separate calls to a helper. Creation statements and authored caller statements are mapped; small named helper functions are returned whole. Variable assignments are not a stack/data-flow history. A missing source origin increments `unmappedAnimations`, rather than guessing an editable location. +Shared helpers appear once with all **queried** targets using them. `coTargets` names up to three observed additional DOM targets of the same tween, prioritizing elements outside the query, and `coTargetsOmitted` counts the rest; it does not claim to enumerate all separate calls to a helper. Creation statements and authored caller statements are mapped; small named helper functions are returned whole. Variable assignments are not a stack/data-flow history. A missing source origin increments `unmappedAnimations`, rather than guessing an editable location. HTML snippets are uniquely identified authored opening tags (their full source lines); dynamically generated markup may have no authored tag match. CSS is an authored-selector candidate scan, not proof of active cascade, media-query state, or inherited styles. In particular, a class reused in different scoped scene styles can have multiple candidate source rules. diff --git a/packages/inspector/src/mapInspection.ts b/packages/inspector/src/mapInspection.ts index 07101e69b3a..b57cb7128f8 100644 --- a/packages/inspector/src/mapInspection.ts +++ b/packages/inspector/src/mapInspection.ts @@ -1,11 +1,5 @@ import { Sources, markupSnippet, mergeSnippets } from "./sources"; import type { InspectRequest, RuntimeResponse, Snippet } from "./types"; -const coverage = [ - "GSAP associations observed since page initialization; callback-created animations may require an explicit time query.", - "Animation source dependencies include lexically resolved declarations in the same script; later mutations, imports, and cross-script bindings are not traced.", - "CSS selector matches are candidates, not a full cascade or dependency graph. HTML is the authored opening tag when uniquely located.", - "Direct constructors, other animation systems, iframe contents, removed animations and unexecuted paths may be absent; missing origins are reported.", -]; const KEPT_FIRST_UNDER_BUDGET = [ "animation creation", "animation caller", @@ -21,6 +15,14 @@ const rank = (kind: string) => { const priority = (snippet: Snippet) => Math.min(...snippet.kind.map(rank)); const byPosition = (a: Snippet, b: Snippet) => a.file.localeCompare(b.file) || a.startLine - b.startLine; +const CO_TARGETS_SHOWN = 3; + +function shown(snippet: Snippet, targetCount: number) { + const { id, kind, code, uniqueInFile, targets } = snippet; + return targetCount === 1 + ? { id, kind, code, uniqueInFile } + : { id, kind, targets, code, uniqueInFile }; +} export function mapInspection( raw: RuntimeResponse, @@ -39,6 +41,7 @@ export function mapInspection( const { sources, map, catalog, generation, sourceVersion, reused, start, visualState } = context; const maxBytes = request.maxBytes ?? 12000; const collected: Snippet[] = []; + const queriedRefs = new Set(raw.results.flatMap((result) => result.elements.map((el) => el.ref))); const results = raw.results.map((result, index) => { let unmappedAnimations = 0; for (const animation of result.animations) { @@ -67,20 +70,24 @@ export function mapInspection( if (snippet) collected.push(snippet); } } + const coTargets = [ + ...new Map( + result.animations + .flatMap((animation) => animation.targets) + .filter((element) => !result.elements.some((own) => own.ref === element.ref)) + .map((element) => [element.ref, element]), + ).values(), + ].sort((a, b) => Number(queriedRefs.has(a.ref)) - Number(queriedRefs.has(b.ref))); return { target: index, status: result.status, candidates: result.candidates, candidatesOmitted: 0, animations: result.animations.length, - coTargets: [ - ...new Map( - result.animations - .flatMap((animation) => animation.targets) - .filter((element) => !result.elements.some((own) => own.ref === element.ref)) - .map((element) => [element.ref, element]), - ).values(), - ].slice(0, 10), + coTargets: coTargets.slice(0, CO_TARGETS_SHOWN), + ...(coTargets.length > CO_TARGETS_SHOWN && { + coTargetsOmitted: coTargets.length - CO_TARGETS_SHOWN, + }), unmappedAnimations, scopeTruncated: result.truncated, }; @@ -100,11 +107,10 @@ export function mapInspection( sourceVersion: sourceVersion, reused, results, - snippets: [] as Snippet[], + snippets: [] as ReturnType[], omitted: [] as { id: string; bytes: number }[], omittedCount: 0, nextOffset: null as number | null, - coverage, recordLimitReached: raw.recordLimitReached, hooked: raw.hooked, debugAvailable: true, @@ -125,8 +131,10 @@ export function mapInspection( } }; trimCandidates(maxBytes / 2, 5); + const targetCount = request.targets.length; + const kept: Snippet[] = []; for (let i = offset; i < selected.length; i++) { - response.snippets.push(selected[i]); + response.snippets.push(shown(selected[i], targetCount)); if (size() > maxBytes - 700) { response.snippets.pop(); response.omittedCount = selected.length - i; @@ -137,8 +145,10 @@ export function mapInspection( response.nextOffset = nothingFit ? (i + 1 < selected.length ? i + 1 : null) : i; break; } + kept.push(selected[i]); } - if (!request.snippets) response.snippets.sort(byPosition); + if (!request.snippets) + response.snippets = kept.sort(byPosition).map((snippet) => shown(snippet, targetCount)); trimCandidates(maxBytes, 0); if (Buffer.byteLength(JSON.stringify(response)) > maxBytes) throw new Error("metadata_exceeds_budget: use fewer targets or increase maxBytes"); diff --git a/packages/inspector/tests/browser.ts b/packages/inspector/tests/browser.ts index b596697df14..f7a0196bb1b 100644 --- a/packages/inspector/tests/browser.ts +++ b/packages/inspector/tests/browser.ts @@ -1,4 +1,5 @@ -import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { snippetSource } from "./snippetSource"; +import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import assert from "node:assert/strict"; @@ -39,18 +40,10 @@ try { assert( cold.snippets.some((s) => s.code.includes("function move") || s.code.includes("tl.to(element")), ); - assert(cold.snippets.some((s) => s.targets.length === 2 && s.code.includes("tl.to(element"))); + assert(cold.snippets.some((s) => s.targets?.length === 2 && s.code.includes("tl.to(element"))); checks.push("multi-target source deduplication"); - for (const snippet of cold.snippets) { - const text = readFileSync(join(project, snippet.file), "utf8"); - assert.equal( - snippet.code, - text - .split(/(?<=\n)/) - .slice(snippet.startLine - 1, snippet.endLine) - .join(""), - ); - } + for (const snippet of cold.snippets) + assert.equal(snippet.code, snippetSource(project, snippet.id)); checks.push("literal snippets equal original bytes"); const single = await inspector.inspect({ targets: [{ selector: "#title" }] }); const ambiguity = await inspector.inspect({ @@ -65,7 +58,9 @@ try { ["found", "missing", "found"], ); assert.equal(ambiguity.results[0].candidates.length, 2); - assert(ambiguity.snippets.some((s) => s.targets.includes(0) && s.code.includes("tl.to(element"))); + assert( + ambiguity.snippets.some((s) => s.targets?.includes(0) && s.code.includes("tl.to(element")), + ); const overlapping = await inspector.inspect({ targets: [{ selector: "#scene-a, #title" }] }); assert.equal(overlapping.results[0].animations, single.results[0].animations); checks.push("class groups, overlapping scopes, partial success and optional within"); diff --git a/packages/inspector/tests/film.ts b/packages/inspector/tests/film.ts index 56ade76fd64..98d2633c325 100644 --- a/packages/inspector/tests/film.ts +++ b/packages/inspector/tests/film.ts @@ -1,3 +1,4 @@ +import { snippetSource } from "./snippetSource"; import { Inspector } from "../src/service"; import { cpSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; @@ -57,16 +58,8 @@ try { notes.afterState = await state(); assert.equal(inspector.stats.browserStarts, 1); assert.equal(inspector.stats.pageLoads, 2); - for (const snippet of after.snippets) { - const text = readFileSync(join(project, snippet.file), "utf8"); - assert.equal( - snippet.code, - text - .split(/(?<=\n)/) - .slice(snippet.startLine - 1, snippet.endLine) - .join(""), - ); - } + for (const snippet of after.snippets) + assert.equal(snippet.code, snippetSource(project, snippet.id)); const afterState = notes.afterState as typeof before; assert.deepEqual(afterState.timelines, before.timelines); const parity = []; diff --git a/packages/inspector/tests/live.ts b/packages/inspector/tests/live.ts index 72c3c8c5cab..c622905d290 100644 --- a/packages/inspector/tests/live.ts +++ b/packages/inspector/tests/live.ts @@ -1,3 +1,4 @@ +import { snippetSource } from "./snippetSource"; import assert from "node:assert/strict"; import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -89,17 +90,10 @@ try { assert.equal(result.results[0]!.unmappedAnimations, 0); assert( result.snippets.some( - (s) => s.code.includes("function move") && s.targets.includes(0) && s.targets.includes(1), + (s) => s.code.includes("function move") && s.targets?.includes(0) && s.targets.includes(1), ), ); - for (const s of result.snippets) - assert.equal( - s.code, - readFileSync(join(dir, s.file), "utf8") - .split(/(?<=\n)/) - .slice(s.startLine - 1, s.endLine) - .join(""), - ); + for (const s of result.snippets) assert.equal(s.code, snippetSource(dir, s.id)); const timeline = await live.timeline(runner, { at: 2, component: { selector: "#title" } }); assert.equal(timeline.status, "found"); assert(timeline.text.includes("index.html:"), "timeline maps its animations to source lines"); diff --git a/packages/inspector/tests/mapInspection.test.ts b/packages/inspector/tests/mapInspection.test.ts index a9e4e120695..1526fb40c8c 100644 --- a/packages/inspector/tests/mapInspection.test.ts +++ b/packages/inspector/tests/mapInspection.test.ts @@ -184,7 +184,7 @@ describe("components report priority", () => { it("returns the snippets it keeps in file order", () => { const { report } = withAnimation({ targets: [{ selector: ".w-dot" }], maxBytes: 256000 }); - const lines = report.snippets.map((s) => s.startLine); + const lines = report.snippets.map((s) => Number(/:(\d+)-/.exec(s.id)![1])); expect(lines).toEqual([...lines].sort((a, b) => a - b)); expect(report.snippets.at(-1)?.kind).toEqual(["animation creation"]); expect(report.omittedCount).toBe(0); @@ -206,3 +206,81 @@ describe("components report priority", () => { expect(fetched.omittedCount).toBe(0); }); }); + +describe("components report shape", () => { + const call = `tl.to(".w-dot", { x: 100, duration: 1 }, 2);`; + + function report(targets: number, coTargets: number) { + const dir = mkdtempSync(join(tmpdir(), "inspect-shape-")); + const html = `\n\n`; + writeFileSync(join(dir, "index.html"), html); + const sources = new Sources(dir); + const start = html.indexOf(call); + const creation = sources.slice( + "index.html", + start, + start + call.length, + "animation creation", + 0, + )!; + const moved = Array.from({ length: coTargets + 1 }, (_, n) => element(n)); + const result = (n: number) => ({ + target: { selector: `.w-dot.t${n}` }, + status: "found" as const, + candidates: [element(n)], + animations: [{ stack: "s", attachmentStacks: [], targets: moved } as never], + elements: [element(n)], + css: [], + truncated: false, + }); + try { + return mapInspection( + { + results: Array.from({ length: targets }, (_, n) => result(n)), + recorded: 1, + recordLimitReached: false, + hooked: true, + }, + { targets: Array.from({ length: targets }, (_, n) => ({ selector: `.w-dot.t${n}` })) }, + { + sources, + map: () => [{ ...creation, targets: Array.from({ length: targets }, (_, n) => n) }], + catalog: new Map(), + generation: "g", + sourceVersion: "v", + reused: false, + start: performance.now(), + }, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + + it("names a snippet's file and lines once, in its id, and carries no fixed disclaimers", () => { + const shown = report(1, 0); + expect(shown).not.toHaveProperty("coverage"); + expect(shown.snippets).toEqual([ + { id: "index.html:4-4", kind: ["animation creation"], code: `${call}\n`, uniqueInFile: true }, + ]); + }); + + it("says which target each snippet serves when the query names more than one", () => { + expect(report(2, 0).snippets[0]).toMatchObject({ id: "index.html:4-4", targets: [0, 1] }); + }); + + it("keeps an unqueried co-target visible when four queried elements share its tween", () => { + for (const result of report(4, 4).results) { + expect(result.coTargets[0]?.ref).toBe(element(4).ref); + expect(result.coTargets).toHaveLength(3); + expect(result.coTargetsOmitted).toBe(1); + } + }); + + it("lists three elements the same animation also moves and counts the rest", () => { + const [result] = report(1, 7).results; + expect(result!.coTargets).toHaveLength(3); + expect(result!.coTargetsOmitted).toBe(4); + expect(report(1, 2).results[0]).not.toHaveProperty("coTargetsOmitted"); + }); +}); diff --git a/packages/inspector/tests/snippetSource.ts b/packages/inspector/tests/snippetSource.ts new file mode 100644 index 00000000000..a7d589bd29a --- /dev/null +++ b/packages/inspector/tests/snippetSource.ts @@ -0,0 +1,11 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** The file lines a snippet id, `:-`, names, as they are on disk now. */ +export function snippetSource(project: string, id: string): string { + const [, file, startLine, endLine] = /^(.+):(\d+)-(\d+)$/.exec(id)!; + return readFileSync(join(project, file!), "utf8") + .split(/(?<=\n)/) + .slice(Number(startLine) - 1, Number(endLine)) + .join(""); +}