diff --git a/.github/workflows/gallery-pr.yml b/.github/workflows/gallery-pr.yml new file mode 100644 index 000000000000..252bc385ccb8 --- /dev/null +++ b/.github/workflows/gallery-pr.yml @@ -0,0 +1,252 @@ +name: gallery PR diff + +# The per-PR gallery (webviews/src/gallery): for the entries a PR touches +# (webviews/scripts/gallery/touched.ts), render entry x variant x theme at the PR's merge-base and +# at its head, the head twice (a state that differs from itself is nondeterministic, never a PR +# change), pixel-diff each pair (scripts/gallery-matrix/compare.ts) and post one sticky comment +# with the changed states and their before/after thumbnails. +# +# `render` runs the PR's code with a read-only token and no secrets, on Linux (never a Mac mini). +# `publish` holds the write token and runs only the base branch's scripts: it rebuilds the comment +# from the render's outcomes.json, uploads the comment's thumbnails to the pr-media branch and +# updates the comment. Same-repository PRs only. +on: + pull_request: + branches: [feat-cmux-next] + types: [opened, synchronize, reopened] + paths: + - "webviews/**" + - "scripts/gallery-matrix/**" + - "scripts/cmux-next/build-agent-pane-web.sh" + - "bun.lock" + - ".github/workflows/gallery-pr.yml" + +permissions: + contents: read + +concurrency: + group: gallery-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +env: + # The matrix: both default themes (dark and light), both engines, the pane at its normal width. + GALLERY_THEMES: pair + GALLERY_ENGINES: chromium,webkit + +jobs: + render: + if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + timeout-minutes: 30 + outputs: + touched: ${{ steps.touched.outputs.count }} + base_sha: ${{ steps.base.outputs.sha }} + steps: + - name: Checkout the PR head + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Find the merge-base and the changed files + id: base + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + set -euo pipefail + sha=$(git merge-base "origin/$BASE_REF" HEAD) + echo "sha=$sha" >> "$GITHUB_OUTPUT" + git diff --name-only "$sha" HEAD > "$RUNNER_TEMP/changed.txt" + echo "merge-base $sha; $(wc -l < "$RUNNER_TEMP/changed.txt") changed files" + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: "1.4.2" + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24.11.1" + + - name: Install webviews dependencies + working-directory: webviews + run: bun install --frozen-lockfile + + - name: Test the diff + working-directory: scripts/gallery-matrix + run: | + set -euo pipefail + bun install --frozen-lockfile + bun test + + - name: Find the touched entries + id: touched + working-directory: webviews + run: | + set -euo pipefail + bun scripts/gallery/touched.ts --changed "$RUNNER_TEMP/changed.txt" --out "$RUNNER_TEMP/touched.json" + ids=$(jq -r '.entries | join(",")' "$RUNNER_TEMP/touched.json") + echo "ids=$ids" >> "$GITHUB_OUTPUT" + echo "count=$(jq '.entries | length' "$RUNNER_TEMP/touched.json")" >> "$GITHUB_OUTPUT" + jq -r '.reasons | to_entries[] | "\(.key): \(.value | join(", "))"' "$RUNNER_TEMP/touched.json" + + - name: Write the head manifest and build the head gallery + if: ${{ steps.touched.outputs.count != '0' }} + working-directory: webviews + env: + IDS: ${{ steps.touched.outputs.ids }} + run: | + set -euo pipefail + bun scripts/gallery/manifest.ts --entries "$IDS" --themes "$GALLERY_THEMES" --frame component --out "$RUNNER_TEMP/head.json" + CMUX_GALLERY_OUT_DIR="$RUNNER_TEMP/gallery-head" bun run gallery:build + + # The merge-base renders with its own gallery code: a change to the gallery shows as a change. + # A base without the gallery (or without an entry) renders nothing for it: the head's states are new. + - name: Write the base manifest and build the base gallery + if: ${{ steps.touched.outputs.count != '0' }} + env: + IDS: ${{ steps.touched.outputs.ids }} + BASE_SHA: ${{ steps.base.outputs.sha }} + run: | + set -euo pipefail + git worktree add --detach "$RUNNER_TEMP/base" "$BASE_SHA" + cd "$RUNNER_TEMP/base/webviews" + if [ ! -f vite.config.gallery.ts ] || [ ! -f scripts/gallery/manifest.ts ]; then + echo "[]" > "$RUNNER_TEMP/base.json" + echo "The merge-base has no gallery; every state is new." + exit 0 + fi + bun install --frozen-lockfile + bun scripts/gallery/manifest.ts --entries "$IDS" --themes "$GALLERY_THEMES" --frame component --out "$RUNNER_TEMP/base.json" + CMUX_GALLERY_OUT_DIR="$RUNNER_TEMP/gallery-base" bun run gallery:build + + - name: Render the base, the head and the head again + if: ${{ steps.touched.outputs.count != '0' }} + working-directory: scripts/gallery-matrix + env: + CMUX_GALLERY_IN_VM: "1" + run: | + set -euo pipefail + ./node_modules/.bin/playwright install --with-deps --only-shell chromium webkit + # Every case either side has: a variant only the base has is a removed state. + jq -s '.[0] as $head | $head + [.[1][] | select(.id as $id | any($head[]; .id == $id) | not)]' \ + "$RUNNER_TEMP/head.json" "$RUNNER_TEMP/base.json" > "$RUNNER_TEMP/cases.json" + echo "$(jq length "$RUNNER_TEMP/cases.json") cases x $GALLERY_ENGINES" + render() { + # A stage that fails to mount makes the runner exit 1; compare.ts reports it, so only a + # run that wrote no results.json fails here. + bun runner.ts --manifest "$RUNNER_TEMP/cases.json" --gallery-dir "$1" --output-dir "$2" \ + --engines "$GALLERY_ENGINES" || true + test -f "$2/results.json" + } + if [ -d "$RUNNER_TEMP/gallery-base" ]; then render "$RUNNER_TEMP/gallery-base" "$RUNNER_TEMP/runs/base"; fi + render "$RUNNER_TEMP/gallery-head" "$RUNNER_TEMP/runs/head" + render "$RUNNER_TEMP/gallery-head" "$RUNNER_TEMP/runs/repeat" + + - name: Compare + if: ${{ steps.touched.outputs.count != '0' }} + working-directory: scripts/gallery-matrix + env: + PR: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + BASE_SHA: ${{ steps.base.outputs.sha }} + ARTIFACT_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}#artifacts + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/runs/base" + bun pr.ts --base "$RUNNER_TEMP/runs/base" --head "$RUNNER_TEMP/runs/head" --repeat "$RUNNER_TEMP/runs/repeat" \ + --base-manifest "$RUNNER_TEMP/base.json" --head-manifest "$RUNNER_TEMP/head.json" \ + --out "$RUNNER_TEMP/gallery-pr/diff" --pr "$PR" --head-sha "$HEAD_SHA" --base-sha "$BASE_SHA" \ + --artifact-url "$ARTIFACT_URL" + cp "$RUNNER_TEMP/touched.json" "$RUNNER_TEMP/gallery-pr/" + cp -r "$RUNNER_TEMP/gallery-head" "$RUNNER_TEMP/gallery-pr/gallery" + cp -r "$RUNNER_TEMP/runs/head" "$RUNNER_TEMP/gallery-pr/matrix" + cat "$RUNNER_TEMP/gallery-pr/diff/comment.md" >> "$GITHUB_STEP_SUMMARY" + + - name: Upload the diff, the gallery and the matrix + if: ${{ steps.touched.outputs.count != '0' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: gallery-pr + path: ${{ runner.temp }}/gallery-pr + retention-days: 14 + + publish: + needs: render + if: ${{ needs.render.result == 'success' && needs.render.outputs.touched != '0' }} + runs-on: ubuntu-24.04 # github-hosted-required: trusted scripts only, with the contents and pull-request write token + timeout-minutes: 10 + # contents: write reaches the pr-media branch; the protected branches have their rulesets. + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout the publisher from the base branch + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.pull_request.base.ref }} + persist-credentials: false + sparse-checkout: | + scripts/gallery-matrix + scripts/pr-media.py + sparse-checkout-cone-mode: false + + - name: Check the base branch has the publisher + id: publisher + run: | + if [ -f scripts/gallery-matrix/pr.ts ]; then echo "ok=true" >> "$GITHUB_OUTPUT" + else echo "The base branch has no gallery publisher yet; the comment is in the render job's summary."; fi + + - name: Setup Bun + if: ${{ steps.publisher.outputs.ok == 'true' }} + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: "1.4.2" + + - name: Download the render + if: ${{ steps.publisher.outputs.ok == 'true' }} + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: gallery-pr + path: ${{ runner.temp }}/render + + - name: Upload the thumbnails and update the comment + if: ${{ steps.publisher.outputs.ok == 'true' }} + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + BASE_SHA: ${{ needs.render.outputs.base_sha }} + ARTIFACT_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}#artifacts + run: | + set -euo pipefail + prefix="gallery-${HEAD_SHA:0:11}-" + thumb_base="https://raw.githubusercontent.com/$REPOSITORY/pr-media/$PR/$prefix" + report="$RUNNER_TEMP/report" + (cd scripts/gallery-matrix && bun install --frozen-lockfile --ignore-scripts && \ + bun pr.ts --outcomes "$RUNNER_TEMP/render/diff/outcomes.json" --out "$report" --pr "$PR" \ + --head-sha "$HEAD_SHA" --base-sha "$BASE_SHA" --artifact-url "$ARTIFACT_URL" --thumb-base "$thumb_base") + upload="$RUNNER_TEMP/upload" + mkdir -p "$upload" + while IFS= read -r key; do + # thumbs.txt names plain file names (report.ts SAFE_KEY); take only PNGs from the render. + src="$RUNNER_TEMP/render/diff/thumbs/$key" + if [ -f "$src" ] && [ "$(head -c 8 "$src" | od -An -tx1 | tr -d ' \n')" = "89504e470d0a1a0a" ]; then + cp "$src" "$upload/$prefix$key" + fi + done < "$report/thumbs.txt" + if compgen -G "$upload/*" > /dev/null; then + python3 scripts/pr-media.py --pr "$PR" --repo "$REPOSITORY" --no-gif --force "$upload"/* > /dev/null + fi + body="$report/comment.md" + marker='' + existing=$(gh api --paginate "repos/$REPOSITORY/issues/$PR/comments" \ + --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | startswith(\"$marker\"))) | .id" | head -1) + if [ -n "$existing" ]; then + gh api -X PATCH "repos/$REPOSITORY/issues/comments/$existing" -F "body=@$body" > /dev/null + else + gh api -X POST "repos/$REPOSITORY/issues/$PR/comments" -F "body=@$body" > /dev/null + fi diff --git a/scripts/gallery-matrix/README.md b/scripts/gallery-matrix/README.md index d45ce0660155..a30aac5c655c 100644 --- a/scripts/gallery-matrix/README.md +++ b/scripts/gallery-matrix/README.md @@ -31,6 +31,20 @@ bun runner.ts --manifest manifest.json --gallery-dir dist/gallery --output-dir . The Freestyle path installs the declared Bun dependencies and Playwright browsers in each VM, runs each shard in parallel, records every exact VM id in `.cmux-scratch/pane-protocol/gallery/freestyle-ledger.json`, and PAUSES only those recorded ids in a `finally` block (also on failure and signals). It never lists the account to decide what to pause or delete. A run refuses to start while the ledger holds ids an earlier run neither paused nor deleted; `--freestyle-cleanup` pauses exactly those. Each VM also pauses itself after 300 s of network idleness. The key is read from its file and only sent to the Freestyle API. +## Per-PR diff + +`.github/workflows/gallery-pr.yml` runs on each PR to feat-cmux-next that touches the webviews. `webviews/scripts/gallery/touched.ts` picks the entries the change reaches: an entry's own file, anything its `covers` import, and every entry of a host whose stylesheet changed. A gallery or build-config change picks every entry. The job renders those entries x variants x both default themes x both engines on Linux at the merge-base and at the head, and at the head a second time. + +`pr.ts` (with `compare.ts` and `report.ts`) compares each state and writes `diff/`: + +- `index.html`: changed states first, each as a highlight overlay (changed regions boxed), a before/after slider and an onion skin; then new, removed, broken (the head stage did not mount) and nondeterministic states; unchanged states folded. +- `comment.md`: the sticky PR comment ("7 states changed: agent-pane.composer/streaming, ...") with before/after thumbnails of the changed regions. +- `summary.json`: the same summary for the team feed's PR card. + +A state whose head render differs from a second head render is nondeterministic (a clock or fixture leak). It is listed apart and never counted as a PR change. + +The render job runs the PR's code with no secrets. The publish job runs only the base branch's scripts: it rebuilds the comment from `outcomes.json`, puts the comment's thumbnails on the pr-media branch and updates the comment. The diff page, the PR's gallery and the matrix are in the run's `gallery-pr` artifact. + Tests: ```sh diff --git a/scripts/gallery-matrix/compare.test.ts b/scripts/gallery-matrix/compare.test.ts new file mode 100644 index 000000000000..76bce8982ef0 --- /dev/null +++ b/scripts/gallery-matrix/compare.test.ts @@ -0,0 +1,165 @@ +import { afterAll, expect, test } from "bun:test"; +import { PNG } from "pngjs"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { beforeAfterThumb, changedBoxes, compareRuns, diffMask, type Outcome } from "./compare"; +import { COMMENT_MARKER, commentMarkdown, diffPage, feedSummary, summaryLine } from "./report"; + +const root = mkdtempSync(join(tmpdir(), "gallery-pr-")); +afterAll(() => rmSync(root, { recursive: true, force: true })); + +/** A gray image with optional colored rectangles. */ +function image(width: number, height: number, rects: { x: number; y: number; w: number; h: number; rgb: number[] }[] = []) { + const png = new PNG({ width, height }); + for (let i = 0; i < width * height; i++) png.data.set([128, 128, 128, 255], i * 4); + for (const r of rects) + for (let y = r.y; y < r.y + r.h; y++) for (let x = r.x; x < r.x + r.w; x++) png.data.set([...r.rgb, 255], (y * width + x) * 4); + return png; +} + +test("two far-apart changes are two boxes; near ones merge", () => { + const a = image(200, 100); + const b = image(200, 100, [ + { x: 10, y: 10, w: 6, h: 6, rgb: [255, 0, 0] }, + { x: 20, y: 12, w: 4, h: 4, rgb: [0, 0, 255] }, + { x: 150, y: 70, w: 10, h: 10, rgb: [0, 255, 0] }, + ]); + const diff = diffMask(a, b); + expect(diff.pixels).toBe(36 + 16 + 100); + const boxes = changedBoxes(diff.mask, diff.width, diff.height); + expect(boxes).toHaveLength(2); + expect(boxes[0]).toEqual({ x: 8, y: 8, w: 16, h: 8 }); + expect(boxes[1]).toEqual({ x: 144, y: 64, w: 16, h: 16 }); +}); + +test("identical images have no changed pixels; a size change counts the new area", () => { + expect(diffMask(image(40, 40), image(40, 40)).pixels).toBe(0); + const grown = diffMask(image(40, 40), image(40, 50)); + expect([grown.width, grown.height]).toEqual([40, 50]); + expect(grown.pixels).toBe(400); +}); + +test("the thumbnail puts before and after side by side around the change", () => { + const thumb = beforeAfterThumb(image(400, 300), image(400, 300, [{ x: 100, y: 100, w: 20, h: 20, rgb: [255, 0, 0] }]), [ + { x: 96, y: 96, w: 24, h: 24 }, + ]); + // 24 + 2 * 24 padding on each side = 72 wide per side, plus the 8 px gap. + expect([thumb.width, thumb.height]).toEqual([72 * 2 + 8, 72]); +}); + +function run(name: string, shots: { id: string; png?: PNG; ready?: string; params?: Record }[]) { + const dir = join(root, name); + mkdirSync(dir, { recursive: true }); + const results = shots.map((s) => { + const screenshot = `${s.id}-chromium.png`; + if (s.png) writeFileSync(join(dir, screenshot), PNG.sync.write(s.png)); + return { id: s.id, engine: "chromium", screenshot, ready: s.ready ?? "1", params: s.params ?? { entry: s.id.split("--")[0], variant: s.id.split("--")[1], theme: "Dark" } }; + }); + writeFileSync(join(dir, "results.json"), JSON.stringify(results)); + return dir; +} + +test("runs sort into changed, new, removed, broken, nondeterministic and unchanged", () => { + const same = image(60, 40); + const moved = image(60, 40, [{ x: 4, y: 4, w: 8, h: 8, rgb: [0, 0, 255] }]); + const base = run("base", [ + { id: "pane.composer--idle", png: same }, + { id: "pane.composer--draft", png: same }, + { id: "pane.old--gone", png: same }, + { id: "pane.clock--now", png: same }, + { id: "pane.crash--x", png: same }, + ]); + const head = run("head", [ + { id: "pane.composer--idle", png: moved }, + { id: "pane.composer--draft", png: same }, + { id: "pane.added--fresh", png: same }, + { id: "pane.clock--now", png: same }, + { id: "pane.crash--x", ready: "error" }, + ]); + const repeat = run("repeat", [ + { id: "pane.composer--idle", png: moved }, + { id: "pane.composer--draft", png: same }, + { id: "pane.added--fresh", png: same }, + { id: "pane.clock--now", png: moved }, + { id: "pane.crash--x", ready: "error" }, + ]); + const out = join(root, "diff"); + const ids = (...list: string[]) => new Set(list); + const outcomes = compareRuns({ + baseDir: base, + headDir: head, + repeatDir: repeat, + baseIds: ids("pane.composer--idle", "pane.composer--draft", "pane.old--gone", "pane.clock--now", "pane.crash--x"), + headIds: ids("pane.composer--idle", "pane.composer--draft", "pane.added--fresh", "pane.clock--now", "pane.crash--x"), + outDir: out, + }); + expect(outcomes.map((o) => [o.id, o.status])).toEqual([ + ["pane.composer--idle", "changed"], + ["pane.added--fresh", "new"], + ["pane.old--gone", "removed"], + ["pane.crash--x", "broken"], + ["pane.clock--now", "nondeterministic"], + ["pane.composer--draft", "unchanged"], + ]); + const changed = outcomes[0]!; + expect(changed.boxes).toEqual([{ x: 0, y: 0, w: 16, h: 16 }]); + for (const file of [changed.base!, changed.head!, changed.thumb!]) expect(existsSync(join(out, file))).toBe(true); + // A nondeterministic state is reported, never as a PR change, and has no before image. + expect(outcomes[4]!.base).toBeUndefined(); +}); + +function outcome(o: Partial): Outcome { + const full: Outcome = { + key: "", + id: "", + entry: "agent-pane.composer", + variant: "idle", + theme: "Dark", + engine: "chromium", + status: "changed", + width: 100, + height: 100, + pixels: 10, + ratio: 0.001, + boxes: [{ x: 0, y: 0, w: 8, h: 8 }], + thumb: "thumbs/x.png", + ...o, + }; + full.id ||= `${full.entry}--${full.variant}`; + full.key ||= `${full.id}-${full.engine}.png`; + return full; +} +const meta = { pr: 18189, head: "e574a65ba50ef129", base: "1960804a7c4ea264", links: { diff: "https://g/pr-18189/diff/", thumbBase: "https://raw/pr-media/18189/gallery-e574a65-" } }; + +test("the summary names changed states once each and the other counts", () => { + const list = [ + outcome({ variant: "idle", theme: "Dark" }), + outcome({ variant: "idle", theme: "Light" }), + outcome({ variant: "streaming" }), + outcome({ entry: "pages.diff", variant: "split", status: "new" }), + outcome({ variant: "draft", status: "unchanged" }), + ]; + expect(summaryLine(list)).toBe("3 states changed: agent-pane.composer/idle, agent-pane.composer/streaming · 1 new state"); + expect(summaryLine([outcome({ status: "unchanged" })])).toBe("No state changed"); + const feed = feedSummary(list, meta); + expect(feed.counts.changed).toBe(3); + expect(feed.changed[0]).toMatchObject({ state: "agent-pane.composer/idle (Dark)", thumb: "https://raw/pr-media/18189/gallery-e574a65-agent-pane.composer--idle-chromium.png" }); +}); + +test("the comment is marked, links the diff page and shows thumbnails; nondeterminism is set apart", () => { + const md = commentMarkdown([outcome({}), outcome({ variant: "clock", status: "nondeterministic" })], meta); + expect(md.startsWith(COMMENT_MARKER)).toBe(true); + expect(md).toContain("**1 state changed: agent-pane.composer/idle**"); + expect(md).toContain("[Diff page](https://g/pr-18189/diff/)"); + expect(md).toContain(''); + expect(md).toContain("1 nondeterministic state differed from a second render"); +}); + +test("the diff page embeds its data safely and lists changed states first", () => { + const html = diffPage([outcome({ variant: "" }), outcome({ variant: "same", status: "unchanged" })], meta); + expect(html).not.toContain(""); + expect(html).toContain("PR #18189 gallery diff"); + expect(html.indexOf('"status":"changed"')).toBeLessThan(html.indexOf('"status":"unchanged"')); + expect(readFileSync(new URL("./report.ts", import.meta.url), "utf8")).toContain("onion"); +}); diff --git a/scripts/gallery-matrix/compare.ts b/scripts/gallery-matrix/compare.ts new file mode 100644 index 000000000000..86bd48fbe905 --- /dev/null +++ b/scripts/gallery-matrix/compare.ts @@ -0,0 +1,282 @@ +// The per-PR gallery diff: pairs the matrix rendered at a PR's merge-base with the same matrix at its +// head, finds the changed regions of each state, and sorts states into changed, new, removed, +// unchanged, nondeterministic (the head differs from a second render of itself: a clock or fixture +// leak, never shown as a PR change) and broken (the head stage failed to mount). +import { PNG } from "pngjs"; +import pixelmatch from "pixelmatch"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +export type Shot = { + id: string; + engine: string; + screenshot: string; + params?: Record; + ready?: string | null; +}; +export type Status = "changed" | "new" | "removed" | "nondeterministic" | "broken" | "unchanged"; +export type Box = { x: number; y: number; w: number; h: number }; +export type Outcome = { + /** The screenshot name, the same in every run: `-.png`. */ + key: string; + id: string; + entry: string; + variant: string; + theme: string; + engine: string; + status: Status; + /** Images under the diff folder: base/, head/, thumbs/. */ + base?: string; + head?: string; + thumb?: string; + width: number; + height: number; + pixels: number; + /** Changed pixels over the larger image's pixels, 0..1. */ + ratio: number; + boxes: Box[]; +}; + +/** Statuses in the order the page and the comment list them. */ +export const STATUS_ORDER: Status[] = ["changed", "new", "removed", "broken", "nondeterministic", "unchanged"]; + +const CELL = 8; +const MERGE_CELLS = 3; +const MAX_BOXES = 12; +const THUMB_PAD = 24; +const THUMB_MAX_WIDTH = 960; + +/** Both images on one canvas the size of the larger; the area only one of them has is transparent. */ +function padTo(png: PNG, width: number, height: number): Buffer { + if (png.width === width && png.height === height) return png.data; + const out = Buffer.alloc(width * height * 4); + for (let y = 0; y < png.height; y++) png.data.copy(out, y * width * 4, y * png.width * 4, (y + 1) * png.width * 4); + return out; +} + +/** + * One flag per pixel: 1 where the two images differ. Anti-aliasing counts: both sides render on the + * same machine, so a changed edge is a real change, and noise is the flake guard's to catch. + */ +export function diffMask(a: PNG, b: PNG): { mask: Uint8Array; width: number; height: number; pixels: number } { + const width = Math.max(a.width, b.width); + const height = Math.max(a.height, b.height); + const output = Buffer.alloc(width * height * 4); + pixelmatch(padTo(a, width, height), padTo(b, width, height), output, width, height, { + threshold: 0.1, + includeAA: true, + diffMask: true, + diffColor: [255, 0, 0], + }); + const mask = new Uint8Array(width * height); + let pixels = 0; + for (let i = 0; i < mask.length; i++) { + const o = i * 4; + if (output[o + 3]! > 0) { + mask[i] = 1; + pixels++; + } + } + return { mask, width, height, pixels }; +} + +/** + * The changed regions: the mask in CELL-pixel cells, cells closer than MERGE_CELLS joined, one box + * per group. More than MAX_BOXES groups become their own bounding box, so the overlay stays legible. + */ +export function changedBoxes(mask: Uint8Array, width: number, height: number): Box[] { + const cols = Math.ceil(width / CELL); + const rows = Math.ceil(height / CELL); + const dirty = new Uint8Array(cols * rows); + for (let y = 0; y < height; y++) + for (let x = 0; x < width; x++) if (mask[y * width + x]) dirty[Math.floor(y / CELL) * cols + Math.floor(x / CELL)] = 1; + const group = new Int32Array(cols * rows).fill(-1); + const boxes: Box[] = []; + for (let start = 0; start < dirty.length; start++) { + if (!dirty[start] || group[start] !== -1) continue; + let [x0, y0, x1, y1] = [cols, rows, -1, -1]; + const stack = [start]; + group[start] = boxes.length; + while (stack.length) { + const cell = stack.pop()!; + const cx = cell % cols; + const cy = Math.floor(cell / cols); + [x0, y0, x1, y1] = [Math.min(x0, cx), Math.min(y0, cy), Math.max(x1, cx), Math.max(y1, cy)]; + for (let dy = -MERGE_CELLS; dy <= MERGE_CELLS; dy++) + for (let dx = -MERGE_CELLS; dx <= MERGE_CELLS; dx++) { + const nx = cx + dx; + const ny = cy + dy; + if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue; + const next = ny * cols + nx; + if (dirty[next] && group[next] === -1) { + group[next] = boxes.length; + stack.push(next); + } + } + } + const x = x0 * CELL; + const y = y0 * CELL; + boxes.push({ x, y, w: Math.min((x1 + 1) * CELL, width) - x, h: Math.min((y1 + 1) * CELL, height) - y }); + } + if (boxes.length <= MAX_BOXES) return boxes.sort((a, b) => a.y - b.y || a.x - b.x); + return [union(boxes)]; +} + +function union(boxes: Box[]): Box { + const x = Math.min(...boxes.map((b) => b.x)); + const y = Math.min(...boxes.map((b) => b.y)); + return { + x, + y, + w: Math.max(...boxes.map((b) => b.x + b.w)) - x, + h: Math.max(...boxes.map((b) => b.y + b.h)) - y, + }; +} + +function crop(png: PNG, box: Box): PNG { + const out = new PNG({ width: box.w, height: box.h }); + for (let y = 0; y < box.h; y++) { + const sy = box.y + y; + if (sy >= png.height) break; + const w = Math.max(0, Math.min(box.w, png.width - box.x)); + png.data.copy(out.data, y * box.w * 4, (sy * png.width + box.x) * 4, (sy * png.width + box.x + w) * 4); + } + return out; +} + +/** Halves an image by averaging 2x2 pixels (the matrix renders at device scale 2). */ +function half(png: PNG): PNG { + const width = Math.max(1, Math.floor(png.width / 2)); + const height = Math.max(1, Math.floor(png.height / 2)); + const out = new PNG({ width, height }); + for (let y = 0; y < height; y++) + for (let x = 0; x < width; x++) + for (let c = 0; c < 4; c++) { + let sum = 0; + for (const [dx, dy] of [[0, 0], [1, 0], [0, 1], [1, 1]] as const) + sum += png.data[((y * 2 + dy) * png.width + (x * 2 + dx)) * 4 + c] ?? 0; + out.data[(y * width + x) * 4 + c] = Math.round(sum / 4); + } + return out; +} + +/** + * The changed region of both sides next to each other, before on the left: the region around every + * box, padded, in the larger image's coordinates, halved until it fits the comment's width. + */ +export function beforeAfterThumb(base: PNG, head: PNG, boxes: Box[]): PNG { + const width = Math.max(base.width, head.width); + const height = Math.max(base.height, head.height); + const all = union(boxes); + const x = Math.max(0, all.x - THUMB_PAD); + const y = Math.max(0, all.y - THUMB_PAD); + const region = { x, y, w: Math.min(width, all.x + all.w + THUMB_PAD) - x, h: Math.min(height, all.y + all.h + THUMB_PAD) - y }; + const gap = 8; + let out = new PNG({ width: region.w * 2 + gap, height: region.h }); + out.data.fill(0); + for (const [side, png] of [[0, base], [1, head]] as const) { + const part = crop(png, region); + for (let row = 0; row < region.h; row++) + part.data.copy(out.data, (row * out.width + side * (region.w + gap)) * 4, row * region.w * 4, (row + 1) * region.w * 4); + } + while (out.width > THUMB_MAX_WIDTH) out = half(out); + return out; +} + +const readPng = (file: string) => PNG.sync.read(readFileSync(file)); + +function readShots(dir: string): Map { + const file = join(dir, "results.json"); + if (!existsSync(file)) return new Map(); + const shots = JSON.parse(readFileSync(file, "utf8")) as Shot[]; + return new Map(shots.map((shot) => [shot.screenshot, shot])); +} + +export type CompareRuns = { + /** The matrix at the merge-base, at the head, and the head rendered a second time. */ + baseDir: string; + headDir: string; + repeatDir?: string; + /** Case ids each side's gallery has (its manifest); an id only the head has is new. */ + baseIds: Set; + headIds: Set; + outDir: string; +}; + +/** Compares the runs and writes base/, head/ and thumbs/ under outDir for the changed states. */ +export function compareRuns(args: CompareRuns): Outcome[] { + const base = readShots(args.baseDir); + const head = readShots(args.headDir); + const repeat = args.repeatDir ? readShots(args.repeatDir) : undefined; + for (const dir of ["base", "head", "thumbs"]) mkdirSync(join(args.outDir, dir), { recursive: true }); + const keep = (side: "base" | "head", dir: string, key: string) => { + copyFileSync(join(dir, key), join(args.outDir, side, key)); + return `${side}/${key}`; + }; + const outcomes: Outcome[] = []; + for (const key of [...new Set([...base.keys(), ...head.keys()])].sort()) { + const shot = (head.get(key) ?? base.get(key))!; + const params = shot.params ?? {}; + const inBase = args.baseIds.has(shot.id) && base.get(key)?.ready === "1"; + const inHead = args.headIds.has(shot.id); + const outcome: Outcome = { + key, + id: shot.id, + entry: String(params.entry ?? shot.id), + variant: String(params.variant ?? ""), + theme: String(params.theme ?? ""), + engine: shot.engine, + status: "unchanged", + width: 0, + height: 0, + pixels: 0, + ratio: 0, + boxes: [], + }; + outcomes.push(outcome); + if (inHead && head.get(key)?.ready !== "1") { + outcome.status = "broken"; + if (head.has(key) && existsSync(join(args.headDir, key))) outcome.head = keep("head", args.headDir, key); + continue; + } + if (!inHead) { + outcome.status = "removed"; + if (inBase) outcome.base = keep("base", args.baseDir, key); + continue; + } + const headPng = readPng(join(args.headDir, key)); + [outcome.width, outcome.height] = [headPng.width, headPng.height]; + const again = repeat?.get(key); + if (repeat && again?.ready === "1") { + const self = diffMask(headPng, readPng(join(args.repeatDir!, key))); + if (self.pixels > 0) { + outcome.status = "nondeterministic"; + outcome.pixels = self.pixels; + outcome.ratio = self.pixels / (self.width * self.height); + outcome.boxes = changedBoxes(self.mask, self.width, self.height); + outcome.head = keep("head", args.headDir, key); + continue; + } + } + if (!inBase) { + outcome.status = "new"; + outcome.head = keep("head", args.headDir, key); + continue; + } + const basePng = readPng(join(args.baseDir, key)); + const diff = diffMask(basePng, headPng); + if (diff.pixels === 0) continue; + outcome.status = "changed"; + [outcome.width, outcome.height] = [diff.width, diff.height]; + outcome.pixels = diff.pixels; + outcome.ratio = diff.pixels / (diff.width * diff.height); + outcome.boxes = changedBoxes(diff.mask, diff.width, diff.height); + outcome.base = keep("base", args.baseDir, key); + outcome.head = keep("head", args.headDir, key); + outcome.thumb = `thumbs/${key}`; + writeFileSync(join(args.outDir, outcome.thumb), PNG.sync.write(beforeAfterThumb(basePng, headPng, outcome.boxes))); + } + return outcomes.sort( + (a, b) => STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status) || b.ratio - a.ratio || a.key.localeCompare(b.key), + ); +} diff --git a/scripts/gallery-matrix/pr.ts b/scripts/gallery-matrix/pr.ts new file mode 100644 index 000000000000..469d4e33304a --- /dev/null +++ b/scripts/gallery-matrix/pr.ts @@ -0,0 +1,77 @@ +#!/usr/bin/env bun +// The per-PR gallery diff from three matrix runs (runner.ts output folders): the merge-base, the +// head and the head again. Writes the diff folder: index.html, outcomes.json, summary.json (the +// feed card), comment.md (the sticky PR comment) and the images they show. +// +// bun pr.ts --base base-run --head head-run --repeat head-run-2 \ +// --base-manifest base.json --head-manifest head.json --out diff \ +// --pr 18189 --head-sha --base-sha [--diff-url U] [--gallery-url U] [--matrix-url U] +// [--artifact-url U] [--thumb-base U] +// +// With --outcomes (an earlier run's outcomes.json) it only writes the reports, so a publisher can +// rebuild the comment from data without running the comparison or the PR's code. +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { parseArgs } from "node:util"; +import { compareRuns, type Outcome } from "./compare"; +import { commentMarkdown, commentThumbs, diffPage, feedSummary, type ReportMeta } from "./report"; + +const caseIds = (file: string | undefined) => + new Set( + file && existsSync(file) ? (JSON.parse(readFileSync(file, "utf8")) as { id: string }[]).map((c) => c.id) : [], + ); + +if (import.meta.main) { + const { values } = parseArgs({ + options: { + base: { type: "string" }, + head: { type: "string" }, + repeat: { type: "string" }, + "base-manifest": { type: "string" }, + "head-manifest": { type: "string" }, + out: { type: "string" }, + outcomes: { type: "string" }, + pr: { type: "string" }, + "head-sha": { type: "string", default: "" }, + "base-sha": { type: "string", default: "" }, + "diff-url": { type: "string" }, + "gallery-url": { type: "string" }, + "matrix-url": { type: "string" }, + "thumb-base": { type: "string" }, + "artifact-url": { type: "string" }, + }, + }); + const required = values.outcomes ? (["out", "pr"] as const) : (["base", "head", "head-manifest", "out", "pr"] as const); + for (const name of required) if (!values[name]) throw new Error(`--${name} is required`); + const out = values.out!; + mkdirSync(out, { recursive: true }); + const outcomes: Outcome[] = values.outcomes + ? JSON.parse(readFileSync(values.outcomes, "utf8")) + : compareRuns({ + baseDir: values.base!, + headDir: values.head!, + repeatDir: values.repeat, + baseIds: caseIds(values["base-manifest"]), + headIds: caseIds(values["head-manifest"]), + outDir: out, + }); + const meta: ReportMeta = { + pr: Number(values.pr), + head: values["head-sha"]!, + base: values["base-sha"]!, + links: { + diff: values["diff-url"], + gallery: values["gallery-url"], + matrix: values["matrix-url"], + thumbBase: values["thumb-base"], + artifact: values["artifact-url"], + }, + }; + writeFileSync(join(out, "outcomes.json"), `${JSON.stringify(outcomes, null, 2)}\n`); + writeFileSync(join(out, "summary.json"), `${JSON.stringify(feedSummary(outcomes, meta), null, 2)}\n`); + writeFileSync(join(out, "comment.md"), commentMarkdown(outcomes, meta)); + // thumbs.txt: the keys whose thumbnails the comment shows, for the publisher to upload. + writeFileSync(join(out, "thumbs.txt"), commentThumbs(outcomes, meta).map((o) => `${o.key}\n`).join("")); + if (!values.outcomes) writeFileSync(join(out, "index.html"), diffPage(outcomes, meta)); + console.log(feedSummary(outcomes, meta).summary); +} diff --git a/scripts/gallery-matrix/report.ts b/scripts/gallery-matrix/report.ts new file mode 100644 index 000000000000..25d498f1cdf1 --- /dev/null +++ b/scripts/gallery-matrix/report.ts @@ -0,0 +1,217 @@ +// What a reader sees of the per-PR gallery diff (compare.ts): the diff page (changed states first, +// each as a highlight overlay, a slider and an onion skin; unchanged states folded), the sticky PR +// comment, and the summary the team feed shows on the PR's card. +import { STATUS_ORDER, type Outcome, type Status } from "./compare"; + +export type ReportLinks = { + /** The diff page, the PR's gallery and its matrix index, when published. */ + diff?: string; + gallery?: string; + matrix?: string; + /** The CI run whose artifact holds the diff page and the renders, until a host serves them. */ + artifact?: string; + /** The thumbnails' URL prefix: a state's thumbnail is this followed by its key (pr-media raw URLs). */ + thumbBase?: string; +}; +export type ReportMeta = { pr: number; head: string; base: string; links: ReportLinks }; + +export const COMMENT_MARKER = ""; +export const COMMENT_THUMBS = 6; +const SUMMARY_STATES = 5; + +const LABELS: Record = { + changed: ["state changed", "states changed"], + new: ["new state", "new states"], + removed: ["removed state", "removed states"], + broken: ["broken state", "broken states"], + nondeterministic: ["nondeterministic state", "nondeterministic states"], + unchanged: ["state unchanged", "states unchanged"], +}; + +export function counts(outcomes: Outcome[]): Record { + const out = Object.fromEntries(STATUS_ORDER.map((status) => [status, 0])) as Record; + for (const outcome of outcomes) out[outcome.status]++; + return out; +} + +const plural = (n: number, status: Status) => `${n} ${LABELS[status][n === 1 ? 0 : 1]}`; + +/** `entry/variant`, with the theme and engine only where the matrix varies them. */ +export function stateLabel(outcome: Outcome, outcomes: Outcome[]): string { + const themes = new Set(outcomes.map((o) => o.theme)).size > 1; + const engines = new Set(outcomes.map((o) => o.engine)).size > 1; + const extra = [themes ? outcome.theme : "", engines ? outcome.engine : ""].filter(Boolean); + return `${outcome.entry}/${outcome.variant}${extra.length ? ` (${extra.join(", ")})` : ""}`; +} + +/** "7 states changed: agent-pane.composer/streaming, ... · 1 new state", or that nothing changed. */ +export function summaryLine(outcomes: Outcome[]): string { + const n = counts(outcomes); + const changed = outcomes.filter((o) => o.status === "changed"); + // One name per entry/variant: the themes and engines of one state usually change together. + const names = [...new Set(changed.map((o) => `${o.entry}/${o.variant}`))]; + const head = n.changed + ? `${plural(n.changed, "changed")}: ${names.slice(0, SUMMARY_STATES).join(", ")}${names.length > SUMMARY_STATES ? ", ..." : ""}` + : "No state changed"; + const rest = (["new", "removed", "broken"] as const).filter((s) => n[s]).map((s) => plural(n[s], s)); + return [head, ...rest].join(" · "); +} + +export function feedSummary(outcomes: Outcome[], meta: ReportMeta) { + return { + pr: meta.pr, + head: meta.head, + base: meta.base, + summary: summaryLine(outcomes), + counts: counts(outcomes), + links: { diff: meta.links.diff, gallery: meta.links.gallery, matrix: meta.links.matrix }, + changed: outcomes + .filter((o) => o.status === "changed") + .map((o) => ({ + state: stateLabel(o, outcomes), + entry: o.entry, + variant: o.variant, + theme: o.theme, + engine: o.engine, + ratio: o.ratio, + thumb: thumbUrl(o, meta), + })), + }; +} + +// Outcomes may come from an artifact the PR's own code wrote (the publisher reads outcomes.json), so +// a key becomes a URL only when it is a plain file name, and labels are escaped for a table cell. +const SAFE_KEY = /^[A-Za-z0-9._-]+$/; + +export const thumbUrl = (o: Outcome, meta: ReportMeta) => + o.thumb && meta.links.thumbBase && SAFE_KEY.test(o.key) ? `${meta.links.thumbBase}${o.key}` : undefined; + +/** The changed states whose thumbnails the comment shows; the publisher uploads exactly these. */ +export const commentThumbs = (outcomes: Outcome[], meta: ReportMeta) => + outcomes.filter((o) => o.status === "changed" && thumbUrl(o, meta)).slice(0, COMMENT_THUMBS); + +const escapeMd = (text: string) => text.replace(/[\r\n]+/g, " ").replace(/[|[\]<>`*_\\]/g, (c) => `\\${c}`); + +export function commentMarkdown(outcomes: Outcome[], meta: ReportMeta): string { + const n = counts(outcomes); + const lines = [COMMENT_MARKER, "### Gallery", "", `**${escapeMd(summaryLine(outcomes))}**`, ""]; + const links = [ + meta.links.diff && `[Diff page](${meta.links.diff})`, + meta.links.gallery && `[Gallery at this head](${meta.links.gallery})`, + meta.links.matrix && `[Matrix](${meta.links.matrix})`, + !meta.links.diff && meta.links.artifact && `[Diff page and renders](${meta.links.artifact}) (the run's \`gallery-pr\` artifact)`, + ].filter(Boolean); + if (links.length) lines.push(links.join(" · "), ""); + const changed = outcomes.filter((o) => o.status === "changed" && thumbUrl(o, meta)); + if (changed.length) { + lines.push("| State | Before · after |", "| --- | --- |"); + for (const o of commentThumbs(outcomes, meta)) + lines.push(`| ${escapeMd(stateLabel(o, outcomes))} | |`); + if (changed.length > COMMENT_THUMBS) lines.push("", `${changed.length - COMMENT_THUMBS} more on the diff page.`); + lines.push(""); + } + if (n.nondeterministic) + lines.push( + `${plural(n.nondeterministic, "nondeterministic")} differed from a second render of the same head (a clock or fixture leak) and ${n.nondeterministic === 1 ? "is" : "are"} not counted as changes.`, + "", + ); + lines.push(`${n.unchanged} unchanged · base ${meta.base.slice(0, 11)} · head ${meta.head.slice(0, 11)}`); + return `${lines.join("\n")}\n`; +} + +/** The diff page: plain HTML and a small script, images beside it (base/, head/, thumbs/). */ +export function diffPage(outcomes: Outcome[], meta: ReportMeta): string { + const data = JSON.stringify({ outcomes, labels: outcomes.map((o) => stateLabel(o, outcomes)) }).replace(/ + + + +${title} + +

${title}

+
+ + +`; +} diff --git a/webviews/scripts/gallery/touched.ts b/webviews/scripts/gallery/touched.ts new file mode 100644 index 000000000000..f45a095bd9ea --- /dev/null +++ b/webviews/scripts/gallery/touched.ts @@ -0,0 +1,171 @@ +#!/usr/bin/env bun +// Which gallery entries a change touches, for the per-PR gallery (.github/workflows/gallery-pr.yml): +// an entry is touched when a changed file is its own `*.gallery.ts`, or is reachable through +// relative imports from that file or from one of its `covers`. Following imports catches what +// `covers` leaves out (a hook, a shared helper). A stylesheet is global to the page that loads it, +// so a changed stylesheet of an entry's host (the agent pane's shipped CSS, a page's imports) +// touches every entry of that host. A change to the gallery itself, the build config or the +// lockfile touches every entry. +// +// bun scripts/gallery/touched.ts --changed changed.txt # one repo path per line +// -> {"all": false, "entries": ["agent-pane.composer"], "reasons": {"agent-pane.composer": ["..."]}} +import fs from "node:fs"; +import path from "node:path"; +import { parseArgs } from "node:util"; +import { agentPaneStylesheets } from "../../dev-server/galleryHost"; +import { galleryFiles, SRC } from "./entries"; + +/** Repo paths that re-render every entry when they change. */ +const EVERYTHING = [ + /^webviews\/src\/gallery\//, + /^webviews\/scripts\/gallery\//, + /^webviews\/dev-server\/galleryHost\.ts$/, + /^scripts\/cmux-next\/build-agent-pane-web\.sh$/, + /^webviews\/vite\.config(\.gallery)?\.ts$/, + /^webviews\/package\.json$/, + /^bun\.lock$/, + /^scripts\/gallery-matrix\//, +]; + +const SOURCE_EXTENSIONS = [".ts", ".tsx", ".js", ".mjs", ".css"]; +const IMPORT = /(?:\bfrom\s*|\bimport\s*\(?\s*)["']([^"']+)["']/g; + +export type TouchedEntries = { all: boolean; entries: string[]; reasons: Record }; +export type EntryRoots = { id: string; host: string; file: string; covers: string[] }; + +/** The stage module that mounts each kind of entry (src/gallery/frame/). */ +const HOST_FRAMES: Record = { + "agent-pane": "gallery/frame/agentPane.ts", + "markdown-page": "gallery/frame/pages.ts", + "diff-page": "gallery/frame/pages.ts", + component: "gallery/frame/component.tsx", +}; + +/** The stylesheets a host's page loads: the CSS its stage module reaches, and the pane's shipped CSS. */ +export function hostStylesheets( + host: string, + src: string, + read: (file: string) => string | undefined, + paneStylesheets: () => string[], +): string[] { + const frame = HOST_FRAMES[host]; + const reached = frame ? [...closure([path.join(src, frame)], read)].filter((file) => file.endsWith(".css")) : []; + return host === "agent-pane" ? [...reached, ...paneStylesheets()] : reached; +} + +/** The relative imports of one source file, resolved to files that exist. */ +export function importsOf(file: string, read: (file: string) => string | undefined = readSource): string[] { + const text = read(file); + if (text === undefined) return []; + const out: string[] = []; + for (const match of text.matchAll(IMPORT)) { + const spec = match[1]!.replace(/\?.*$/, ""); + if (!spec.startsWith(".")) continue; + const resolved = resolveImport(path.resolve(path.dirname(file), spec), read); + if (resolved) out.push(resolved); + } + return out; +} + +function resolveImport(base: string, read: (file: string) => string | undefined): string | undefined { + const candidates = [ + base, + ...SOURCE_EXTENSIONS.map((ext) => base + ext), + ...SOURCE_EXTENSIONS.map((ext) => path.join(base, `index${ext}`)), + ]; + // A `.js` specifier may name a `.ts` source. + if (base.endsWith(".js")) candidates.push(base.replace(/\.js$/, ".ts"), base.replace(/\.js$/, ".tsx")); + return candidates.find((candidate) => path.extname(candidate) !== "" && read(candidate) !== undefined); +} + +function readSource(file: string): string | undefined { + try { + return fs.statSync(file).isFile() ? fs.readFileSync(file, "utf8") : undefined; + } catch { + return undefined; + } +} + +/** Every file reachable from `roots` through relative imports, roots included. */ +export function closure(roots: string[], read: (file: string) => string | undefined = readSource): Set { + const seen = new Set(); + const queue = [...roots]; + while (queue.length) { + const file = queue.pop()!; + if (seen.has(file)) continue; + seen.add(file); + queue.push(...importsOf(file, read)); + } + return seen; +} + +/** A cover is `dir/File.tsx#Component` or `dir/`, relative to webviews/src. */ +function coverFiles(cover: string, src: string): string[] { + const rel = cover.replace(/#.*$/, ""); + const full = path.join(src, rel); + if (!rel.endsWith("/")) return [full]; + try { + return fs + .readdirSync(full, { recursive: true, encoding: "utf8" }) + .filter((name) => SOURCE_EXTENSIONS.includes(path.extname(name))) + .map((name) => path.join(full, name)); + } catch { + return []; + } +} + +export function touchedEntries( + entries: EntryRoots[], + changed: string[], + options: { + src?: string; + repoRoot?: string; + read?: (file: string) => string | undefined; + paneStylesheets?: () => string[]; + } = {}, +): TouchedEntries { + const src = options.src ?? SRC; + const repoRoot = options.repoRoot ?? path.resolve(src, "../.."); + const read = options.read ?? readSource; + const paneStylesheets = options.paneStylesheets ?? (() => agentPaneStylesheets()); + const ids = entries.map((entry) => entry.id).sort(); + const broad = changed.filter((file) => EVERYTHING.some((pattern) => pattern.test(file))); + if (broad.length) + return { all: true, entries: ids, reasons: Object.fromEntries(ids.map((id) => [id, broad.slice(0, 5)])) }; + const changedFull = new Map(changed.map((file) => [path.join(repoRoot, file), file])); + const reasons: Record = {}; + const sheets = new Map(); + for (const entry of entries) { + if (!sheets.has(entry.host)) sheets.set(entry.host, hostStylesheets(entry.host, src, read, paneStylesheets)); + const roots = [entry.file, ...entry.covers.flatMap((cover) => coverFiles(cover, src))]; + const reached = new Set([...closure(roots, read), ...sheets.get(entry.host)!]); + const hits = [...reached].flatMap((file) => changedFull.get(file) ?? []); + if (hits.length) reasons[entry.id] = hits.sort(); + } + return { all: false, entries: Object.keys(reasons).sort(), reasons }; +} + +/** The entries under src/ with the file each one comes from. */ +export async function loadEntryRoots(): Promise { + return Promise.all( + galleryFiles().map(async (file) => { + const { default: entry } = (await import(file)) as { default: { id: string; host: string; covers: string[] } }; + return { id: entry.id, host: entry.host, file, covers: entry.covers }; + }), + ); +} + +if (import.meta.main) { + const { values } = parseArgs({ options: { changed: { type: "string" }, out: { type: "string" } } }); + if (!values.changed) throw new Error("--changed is required"); + const changed = fs + .readFileSync(values.changed, "utf8") + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + const result = touchedEntries(await loadEntryRoots(), changed); + const json = `${JSON.stringify(result, null, 2)}\n`; + if (values.out) fs.writeFileSync(values.out, json); + else process.stdout.write(json); + console.error(`gallery: ${result.all ? "every entry" : `${result.entries.length} entries`} touched`); +} diff --git a/webviews/test/gallery-touched.test.ts b/webviews/test/gallery-touched.test.ts new file mode 100644 index 000000000000..fd4ffdbf4a59 --- /dev/null +++ b/webviews/test/gallery-touched.test.ts @@ -0,0 +1,78 @@ +// The per-PR gallery renders only the entries a change reaches (scripts/gallery/touched.ts). +import { expect, test } from "bun:test"; +import path from "node:path"; +import { closure, loadEntryRoots, touchedEntries, type EntryRoots } from "../scripts/gallery/touched"; + +const SRC = "/repo/webviews/src"; +const files: Record = { + [`${SRC}/pane/Composer.tsx`]: `import "./composer.css";\nimport { send } from "./send";\nexport function Composer() {}`, + [`${SRC}/pane/composer.css`]: `.composer{}`, + [`${SRC}/pane/send.ts`]: `export const send = 1; const lazy = () => import("../shared/icons.js");`, + [`${SRC}/shared/icons.ts`]: `import React from "react";`, + [`${SRC}/pane/composer.gallery.ts`]: `import { agentPaneEntry } from "../gallery/format";`, + [`${SRC}/pages/markdown.gallery.ts`]: `import { page } from "./markdown/Page";`, + [`${SRC}/pages/markdown/Page.tsx`]: `export function Page() {}`, + [`${SRC}/gallery/format.ts`]: ``, + [`${SRC}/gallery/frame/agentPane.ts`]: `await import("../../pane/main");`, + [`${SRC}/pane/main.tsx`]: `import "./app.css";\nimport { Composer } from "./Composer";`, + [`${SRC}/pane/app.css`]: `.app{}`, +}; +const read = (file: string) => files[file]; +const entries: EntryRoots[] = [ + { + id: "agent-pane.composer", + host: "agent-pane", + file: `${SRC}/pane/composer.gallery.ts`, + covers: ["pane/Composer.tsx#Composer"], + }, + { + id: "pages.markdown", + host: "markdown-page", + file: `${SRC}/pages/markdown.gallery.ts`, + covers: ["pages/markdown/Page.tsx"], + }, +]; +const paneStylesheets = () => [`${SRC}/pane/shipped.css`]; +const touched = (changed: string[]) => + touchedEntries(entries, changed, { src: SRC, repoRoot: "/repo", read, paneStylesheets }); + +test("imports are followed through stylesheets, helpers and dynamic imports", () => { + expect([...closure([`${SRC}/pane/Composer.tsx`], read)].map((f) => path.relative(SRC, f)).sort()).toEqual([ + "pane/Composer.tsx", + "pane/composer.css", + "pane/send.ts", + "shared/icons.ts", + ]); +}); + +test("a stylesheet or helper a cover imports touches that entry only", () => { + expect(touched(["webviews/src/pane/composer.css"])).toEqual({ + all: false, + entries: ["agent-pane.composer"], + reasons: { "agent-pane.composer": ["webviews/src/pane/composer.css"] }, + }); + expect(touched(["webviews/src/shared/icons.ts"]).entries).toEqual(["agent-pane.composer"]); + expect(touched(["webviews/src/pages/markdown.gallery.ts"]).entries).toEqual(["pages.markdown"]); +}); + +test("a stylesheet the host loads touches every entry of that host, a script does not", () => { + expect(touched(["webviews/src/pane/app.css"]).entries).toEqual(["agent-pane.composer"]); + expect(touched(["webviews/src/pane/shipped.css"]).entries).toEqual(["agent-pane.composer"]); + expect(touched(["webviews/src/pane/main.tsx"]).entries).toEqual([]); +}); + +test("unrelated files touch nothing; the gallery and build config touch everything", () => { + expect(touched(["README.md", "webviews/src/other/Thing.tsx"]).entries).toEqual([]); + const all = touched(["webviews/src/gallery/clock.ts"]); + expect(all.all).toBe(true); + expect(all.entries).toEqual(["agent-pane.composer", "pages.markdown"]); + expect(touched(["webviews/vite.config.gallery.ts"]).all).toBe(true); +}); + +test("the real composer entry is touched by the agent pane stylesheet", async () => { + const roots = await loadEntryRoots(); + const result = touchedEntries(roots, ["webviews/src/agent-session/acpmux/styles.css"]); + expect(result.all).toBe(false); + expect(result.entries).toContain("agent-pane.composer"); + expect(result.entries).not.toContain("pages.markdown"); +});