diff --git a/docs/features.md b/docs/features.md
index bf87bdc..e544ea7 100644
--- a/docs/features.md
+++ b/docs/features.md
@@ -164,6 +164,16 @@ Read what changed with enough surrounding code to judge it.
Large diffs use bounded paging. Captured historical source and the current
workspace remain distinct.
+### Review media
+
+Inspect QA captures and recordings supplied by the launching agent with
+`--media`. Media shows only those files by default, and stays hidden when none
+were supplied. Changed PNGs retain their before/after view in Changes.
+
+To add changed PNGs to Media, use `--media-include-changed-images` or
+`.diffcompass.json` → `media.includeChangedImages: true`. The extra images load
+only when viewed, and the choice survives resume. See [review media options](usage.md#review-media).
+
### Symbol Peek
Follow a definition or usage without losing your place.
diff --git a/docs/usage.md b/docs/usage.md
index d76bc46..299bacd 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -59,6 +59,30 @@ For a stacked branch:
git config branch.my-feature.reviewTourBase parent-feature
```
+## Review media
+
+Pass each QA capture or recording with `--media PATH` (PNG, JPEG, GIF, WebP or
+MP4). Media shows only those session-owned files by default; its count excludes
+images from the diff. With no supplied media, the tab is hidden. Changed PNGs
+remain available in Changes with their captured before/after comparison.
+
+To include changed PNGs in Media as well, add `--media-include-changed-images`
+to a branch or `diffcompass pr` launch, or set this repository default:
+
+```json
+{ "media": { "includeChangedImages": true } }
+```
+
+Each available side contributes one item (two for a modified PNG, one for an
+added or deleted PNG). These optional images load when scrolled into view or
+explicitly opened, rather than during page startup. Supplied `--media` files
+keep their existing import and loading behavior.
+
+The choice is saved with the session. On resume, an explicit CLI flag wins over
+the saved choice; the saved choice wins over repository configuration. Older
+sessions without a saved choice use the repository default, which is false
+unless opted in.
+
## Repository defaults
Add `.diffcompass.json` at the repository root:
diff --git a/public/app.js b/public/app.js
index 0056de6..ca4f2f2 100644
--- a/public/app.js
+++ b/public/app.js
@@ -108,6 +108,9 @@ const state = {
: "github",
commentQuoteHighlightRequestId: 0,
screenshotUrls: new Map(),
+ screenshotLoads: new Map(),
+ screenshotRenderId: 0,
+ changedImageObserver: null,
diffImageUrls: new Map(),
diffImageRequestId: 0,
markdownPreviewPaths: new Set(),
@@ -4376,6 +4379,7 @@ function renderActivities() {
}
function changedImageMedia() {
+ if (state.session?.mediaIncludeChangedImages !== true) return [];
const revision = state.session?.revision ?? 0;
return (state.session?.comparison?.files ?? []).filter(isPngDiff).flatMap((file) => [
file.oldPath ? { id: `diff:${revision}:${file.path}:old`, path: file.path, side: "old", name: `${file.path} · Before` } : null,
@@ -4841,7 +4845,7 @@ function addDryRunScenario() {
}
function availableTabs() {
- return ["briefing", "diff", "archify", "dry-runs", "findings", "activity", "conversations", "screenshots", ...(state.session?.pullRequest ? ["pull-request"] : [])];
+ return ["briefing", "diff", "archify", "dry-runs", "findings", "activity", "conversations", ...((state.session?.screenshots?.length || changedImageMedia().length) ? ["screenshots"] : []), ...(state.session?.pullRequest ? ["pull-request"] : [])];
}
const MAP_VIEWS = ["archify", "call-flows", "sequence", "adaptive", "structural-blast-radius"];
@@ -4887,6 +4891,7 @@ function renderMapsNavigation() {
}
function renderActiveTab() {
+ if (state.activeTab === "screenshots" && !availableTabs().includes("screenshots")) state.activeTab = "diff";
if (state.activeTab !== "diff") state.briefingSourceReturn = false;
for (const tabName of ["briefing", "diff", "call-flows", "structural-blast-radius", "archify", "sequence", "dry-runs", "adaptive", "findings", "activity", "conversations", "screenshots", "pull-request"]) {
const active = state.activeTab === tabName;
@@ -4904,15 +4909,15 @@ function renderActiveTab() {
}
async function renderScreenshots() {
+ const requestId = ++state.screenshotRenderId;
+ state.changedImageObserver?.disconnect();
+ state.changedImageObserver = null;
const screenshots = state.session?.screenshots ?? [];
const changedImages = changedImageMedia();
const mediaCount = screenshots.length + changedImages.length;
elements["screenshots-tab-count"].textContent = String(mediaCount);
elements["screenshots-tab-count"].classList.toggle("hidden", mediaCount === 0);
- if (mediaCount === 0) {
- elements.screenshots.innerHTML = '
No media is available for this review.
';
- return;
- }
+ elements["screenshots-tab"].classList.toggle("hidden", mediaCount === 0);
const visibleIds = new Set([...screenshots.map((shot) => shot.id), ...changedImages.map((image) => image.id)]);
for (const [id, url] of state.screenshotUrls) {
if (!visibleIds.has(id)) {
@@ -4920,39 +4925,64 @@ async function renderScreenshots() {
state.screenshotUrls.delete(id);
}
}
+ if (mediaCount === 0) {
+ elements.screenshots.innerHTML = 'No media is available for this review.
';
+ return;
+ }
+ async function mediaUrl(id, path) {
+ if (state.screenshotUrls.has(id)) return state.screenshotUrls.get(id);
+ let pending = state.screenshotLoads.get(id);
+ if (!pending) {
+ pending = apiBlob(path).finally(() => state.screenshotLoads.delete(id));
+ state.screenshotLoads.set(id, pending);
+ }
+ const blob = await pending;
+ if (requestId !== state.screenshotRenderId) return;
+ if (!state.screenshotUrls.has(id)) state.screenshotUrls.set(id, URL.createObjectURL(blob));
+ return state.screenshotUrls.get(id);
+ }
elements.screenshots.innerHTML = [
...screenshots.map((shot) => `Loading media…
${escapeHtml(shot.name)} ${Math.max(1, Math.round(shot.size / 1024)).toLocaleString()} KiB `),
- ...changedImages.map((image) => `Loading changed image…
${escapeHtml(image.name)} Changed PNG `),
+ ...changedImages.map((image) => `View image
${escapeHtml(image.name)} Changed PNG `),
].join("");
- await Promise.all([...screenshots.map(async (shot) => {
+ async function loadChangedImage(image, card) {
+ if (requestId !== state.screenshotRenderId || !card.isConnected || card.dataset.loading) return;
+ card.dataset.loading = "true";
+ state.changedImageObserver?.unobserve(card);
+ card.querySelector(".screenshot-loading").textContent = "Loading changed image…";
try {
- let url = state.screenshotUrls.get(shot.id);
- if (!url) {
- url = URL.createObjectURL(await apiBlob(`/api/screenshots/${encodeURIComponent(shot.id)}`));
- state.screenshotUrls.set(shot.id, url);
- }
+ const url = await mediaUrl(image.id, `/api/diff/image?${new URLSearchParams({ path: image.path, side: image.side })}`);
+ if (!url || requestId !== state.screenshotRenderId || !card.isConnected) return;
+ card.querySelector(".screenshot-loading").outerHTML = ` `;
+ } catch (error) {
+ if (requestId === state.screenshotRenderId && card.isConnected) card.querySelector(".screenshot-loading").textContent = error.message;
+ }
+ }
+ const imagesById = new Map(changedImages.map(image => [image.id, image]));
+ if (changedImages.length && typeof IntersectionObserver !== "undefined") {
+ state.changedImageObserver = new IntersectionObserver(entries => {
+ if (state.activeTab !== "screenshots" || requestId !== state.screenshotRenderId) return;
+ for (const entry of entries) if (entry.isIntersecting) void loadChangedImage(imagesById.get(entry.target.dataset.changedImageId), entry.target);
+ });
+ }
+ for (const card of elements.screenshots.querySelectorAll("[data-changed-image-id]")) {
+ card.querySelector("button").onclick = () => void loadChangedImage(imagesById.get(card.dataset.changedImageId), card);
+ state.changedImageObserver?.observe(card);
+ }
+ await Promise.all(screenshots.map(async (shot) => {
+ try {
+ const url = await mediaUrl(shot.id, `/api/screenshots/${encodeURIComponent(shot.id)}`);
+ if (!url || requestId !== state.screenshotRenderId) return;
const card = elements.screenshots.querySelector(`[data-screenshot-id="${CSS.escape(shot.id)}"]`);
if (card?.querySelector(".screenshot-loading")) card.querySelector(".screenshot-loading").outerHTML = shot.mimeType === "video/mp4"
? ` `
: ` `;
} catch (error) {
+ if (requestId !== state.screenshotRenderId) return;
const card = elements.screenshots.querySelector(`[data-screenshot-id="${CSS.escape(shot.id)}"]`);
if (card?.querySelector(".screenshot-loading")) card.querySelector(".screenshot-loading").textContent = error.message;
}
- }), ...changedImages.map(async (image) => {
- try {
- let url = state.screenshotUrls.get(image.id);
- if (!url) {
- url = URL.createObjectURL(await apiBlob(`/api/diff/image?${new URLSearchParams({ path: image.path, side: image.side })}`));
- state.screenshotUrls.set(image.id, url);
- }
- const card = elements.screenshots.querySelector(`[data-changed-image-id="${CSS.escape(image.id)}"]`);
- if (card?.querySelector(".screenshot-loading")) card.querySelector(".screenshot-loading").outerHTML = ` `;
- } catch (error) {
- const card = elements.screenshots.querySelector(`[data-changed-image-id="${CSS.escape(image.id)}"]`);
- if (card?.querySelector(".screenshot-loading")) card.querySelector(".screenshot-loading").textContent = error.message;
- }
- })]);
+ }));
}
let briefingReader;
diff --git a/public/index.html b/public/index.html
index 88b6e02..e5494c3 100644
--- a/public/index.html
+++ b/public/index.html
@@ -36,7 +36,7 @@
Findings 0
Activity 0
Conversations
- Media 0
+ Media 0
Pull request
diff --git a/scripts/check-monaco.ts b/scripts/check-monaco.ts
index 24a5a90..2d17b26 100644
--- a/scripts/check-monaco.ts
+++ b/scripts/check-monaco.ts
@@ -23,7 +23,7 @@ try {
}
const groups = {
briefing: { samplePr: false, scripts: ["briefing-entry", "briefing-source", "briefing", "briefing-code", "briefing-diagrams"] },
- local: { samplePr: false, scripts: ["monaco-foundation", "monaco-scroll", "monaco-click-focus", "file-header", "monaco-edits", "monaco-edits-syntax", "monaco-themes", "renderer-preference", "monaco-gutter", "monaco-wrapping", "markdown-preview", "monaco-semantic", "monaco-mount", "monaco-paged-draft"] },
+ local: { samplePr: false, scripts: ["monaco-foundation", "monaco-scroll", "monaco-click-focus", "media", "file-header", "monaco-edits", "monaco-edits-syntax", "monaco-themes", "renderer-preference", "monaco-gutter", "monaco-wrapping", "markdown-preview", "monaco-semantic", "monaco-mount", "monaco-paged-draft"] },
navigation: { samplePr: true, scripts: ["monaco-phase", "monaco-phase-scroll", "monaco-upward-tour", "monaco-reviewed-scroll", "monaco-sticky", "monaco-file-explorer"] },
discussions: { samplePr: true, scripts: ["review-pane", "renderer-comments", "monaco-context", "monaco-edits", "monaco-edits-syntax", "monaco-discussions"] },
before: { samplePr: true, beforeTour: true, scripts: ["monaco-before-tour"] },
diff --git a/scripts/media-browser.mjs b/scripts/media-browser.mjs
new file mode 100644
index 0000000..e025dd4
--- /dev/null
+++ b/scripts/media-browser.mjs
@@ -0,0 +1,80 @@
+import assert from "node:assert/strict";
+import { pathToFileURL } from "node:url";
+const [playwrightPath, url] = process.argv.slice(2);
+const { chromium } = await import(pathToFileURL(playwrightPath).href);
+const browser = await chromium.launch({ channel: process.env.CI ? undefined : "chrome", headless: true });
+const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64");
+const changedCount = 60;
+try {
+ for (const [curatedCount, includeChanged] of [[2, false], [0, false], [2, true], [0, true]]) {
+ const page = await browser.newPage({ viewport: { width: 1400, height: 850 }, reducedMotion: "reduce" });
+ const requested = new Set(), errors = [];
+ page.on("pageerror", error => errors.push(error.message));
+ await page.route("**/api/update", route => route.fulfill({ json: { updateAvailable: false } }));
+ await page.route("**/api/session", async route => {
+ const response = await route.fetch();
+ const session = await response.json();
+ const image = session.comparison.files.find(file => file.path === "screens/receipt.png");
+ assert.ok(image, "Fixture retains a real before/after PNG");
+ session.comparison.files.push(...Array.from({ length: changedCount - 1 }, (_, index) => {
+ const path = `screens/reference-${index}.png`;
+ return { ...image, path, oldPath: path, newPath: path };
+ }));
+ session.screenshots = Array.from({ length: curatedCount }, (_, index) => ({ id: `qa-${index}`, name: `QA ${index}.png`, mimeType: "image/png", size: png.length, fileName: `qa-${index}.png` }));
+ session.mediaIncludeChangedImages = includeChanged;
+ await route.fulfill({ json: session });
+ });
+ await page.route("**/api/screenshots/*", route => route.fulfill({ body: png, contentType: "image/png" }));
+ await page.route("**/api/diff/image?*", async route => {
+ const request = new URL(route.request().url());
+ if (!request.searchParams.get("path").startsWith("screens/reference-")) return route.continue();
+ requested.add(`${request.searchParams.get("path")}:${request.searchParams.get("side")}`);
+ await route.fulfill({ body: png, contentType: "image/png" });
+ });
+ try {
+ await page.goto(url);
+ await page.waitForFunction(() => document.getElementById("diff-renderer")?.disabled === false);
+ const expected = curatedCount + (includeChanged ? 2 * changedCount : 0);
+ await page.waitForFunction(expected => document.getElementById("screenshots-tab-count").textContent === String(expected), expected);
+ await page.waitForTimeout(150);
+ assert.equal(requested.size, 0, "A hidden Media tab must not fetch changed images");
+ assert.equal(await page.locator("#screenshots-tab").isVisible(), expected > 0);
+ if (!expected) {
+ const tabs = await page.locator('.tab:not(.hidden)').evaluateAll(nodes => nodes.map(node => node.id));
+ assert.ok(!tabs.includes("screenshots-tab"), "Changed PNGs alone must not expose Media by default");
+ } else {
+ await page.locator("#screenshots-tab").click();
+ assert.equal(await page.locator("#screenshots .screenshot-card").count(), expected);
+ await page.waitForFunction(count => document.querySelectorAll("#screenshots [data-screenshot-id] img").length === count, curatedCount);
+ assert.equal(await page.locator("#screenshots [data-screenshot-id] img").count(), curatedCount);
+ if (!includeChanged) {
+ assert.equal(requested.size, 0, "Default Media must not fetch screenshot-test references");
+ assert.equal(await page.locator("#screenshots .changed-image-media").count(), 0);
+ } else {
+ const last = page.locator("#screenshots .changed-image-media").last();
+ assert.equal(await last.locator("img").count(), 0, "Offscreen cards remain unloaded");
+ // Explicit activation also works without observer visibility (e.g. assistive UI).
+ await last.locator("button").evaluate(button => button.click());
+ await last.locator("img").waitFor({ state: "attached" });
+ assert.ok(requested.has("screens/reference-58.png:new"));
+ const middle = page.locator("#screenshots .changed-image-media").nth(60);
+ assert.equal(await middle.locator("img").count(), 0);
+ await middle.scrollIntoViewIfNeeded();
+ await middle.locator("img").waitFor({ state: "visible" });
+ assert.ok(requested.size < changedCount, `Scrolling must not fetch the entire gallery (${requested.size} requests)`);
+ }
+ }
+ // The original PNG still opens in Changes with both captured versions.
+ await page.locator("#diff-tab").click();
+ await page.evaluate(() => diffCompassProductionHost.reveal("screens/receipt.png", "new", 1));
+ const comparison = page.locator('.diff-file[data-path="screens/receipt.png"] .image-diff');
+ await comparison.locator(".old img").waitFor({ state: "visible" });
+ await comparison.locator(".new img").waitFor({ state: "visible" });
+ assert.deepEqual(errors, []);
+ console.log(`PASS Media ${expected}: ${curatedCount} curated files, ${changedCount} changed PNGs, opt-in ${includeChanged}; lazy requests and Changes comparison`);
+ } finally {
+ await page.unrouteAll({ behavior: "wait" });
+ await page.close();
+ }
+ }
+} finally { await browser.close(); }
diff --git a/src/cli.ts b/src/cli.ts
index e514f21..1d80ccb 100755
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -32,6 +32,7 @@ interface Options {
disableFindings: boolean;
previewRedirect: boolean;
media: string[];
+ mediaIncludeChangedImages?: boolean;
intentFile?: string;
resume?: string;
pullRequest?: string;
@@ -67,6 +68,8 @@ Options:
--port PORT Loopback port (default: random)
--preview-redirect Redirect / to the tokenized URL so remote previews work
--media PATH Add an image or MP4 to the Media tab (repeatable)
+ --media-include-changed-images
+ Also show changed PNGs in Media (loaded on demand)
--intent-file PATH Give the Briefing author-stated context from a text file
--resume FILE Resume a persisted review session
--version Print the installed version
@@ -100,6 +103,8 @@ function parseOptions(args: string[]): Options {
options.open = true;
} else if (arg === "--preview-redirect") {
options.previewRedirect = true;
+ } else if (arg === "--media-include-changed-images") {
+ options.mediaIncludeChangedImages = true;
} else if (arg === "--media" && value) {
options.media.push(resolve(value));
index += 1;
@@ -264,6 +269,7 @@ try {
includeFindings: options.disableFindings ? false : undefined,
pullRequest,
screenshots: options.media,
+ mediaIncludeChangedImages: options.mediaIncludeChangedImages ?? resumedState?.mediaIncludeChangedImages ?? repositoryConfig.mediaIncludeChangedImages,
authorIntent,
});
if (session.pullRequest?.workspaceRoot) {
diff --git a/src/config.ts b/src/config.ts
index 2cc39ae..8c1f617 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -16,6 +16,7 @@ export interface RepositoryConfig {
preferences: ReviewPreferences;
retention: RetentionConfig;
reviewPolicy: ReviewPolicy;
+ mediaIncludeChangedImages: boolean;
}
export const DEFAULT_REVIEW_POLICY: ReviewPolicy = {
@@ -85,7 +86,7 @@ export async function loadRepositoryConfig(repositoryRoot: string): Promise 24000)) ||
(session.screenshots !== undefined && (!Array.isArray(session.screenshots) || session.screenshots.length > 12 || !session.screenshots.every(validScreenshot) || new Set(session.screenshots.map((shot) => shot.id)).size !== session.screenshots.length)) ||
+ (session.mediaIncludeChangedImages !== undefined && typeof session.mediaIncludeChangedImages !== "boolean") ||
(session.outline !== undefined && !validOutline(session.outline)) ||
(session.selection !== undefined && !validSelection(session.selection)) ||
(session.agentContinuation !== undefined && !validContinuation(session.agentContinuation)) ||
diff --git a/src/types.ts b/src/types.ts
index a8ebce0..2096565 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -941,6 +941,8 @@ export interface PersistedReviewSession {
dryRuns?: DryRunTrace[];
pullRequest?: PullRequestContext;
screenshots?: ReviewScreenshot[];
+ /** Optional for older sessions; changed PNGs are excluded from Media by default. */
+ mediaIncludeChangedImages?: boolean;
authorIntent?: string;
revision: number;
status: ReviewStatus;
diff --git a/tests/config.test.ts b/tests/config.test.ts
index 6e8723f..89330c3 100644
--- a/tests/config.test.ts
+++ b/tests/config.test.ts
@@ -22,6 +22,7 @@ test("missing repository configuration uses conservative defaults", async () =>
preferences: DEFAULT_REVIEW_PREFERENCES,
retention: { includeArchived: false },
reviewPolicy: DEFAULT_REVIEW_POLICY,
+ mediaIncludeChangedImages: false,
});
expect(DEFAULT_REVIEW_PREFERENCES.diffLayout).toBe("split");
});
@@ -35,6 +36,7 @@ test("loads every supported repository default", async () => {
reasoning: "high",
diff: { layout: "split", changedFilesOnly: true, contextLines: 25 },
review: { requireAllFilesReviewed: true, closeOutcome: "cancelled" },
+ media: { includeChangedImages: true },
retention: { maxAgeDays: 30, maxSessions: 50, includeArchived: true },
analysis: {
instructions: ["Treat authentication changes as high risk."],
@@ -43,6 +45,7 @@ test("loads every supported repository default", async () => {
}));
expect(await loadRepositoryConfig(directory)).toEqual({
+ mediaIncludeChangedImages: true,
base: "parent-feature",
agent: "claude",
model: "opus",
@@ -83,6 +86,9 @@ test("fails closed for unknown, mistyped, oversized, and linked configuration",
const path = join(directory, ".diffcompass.json");
for (const value of [
{ unexpected: true },
+ { media: true },
+ { media: { includeChangedImages: "yes" } },
+ { media: { includeImages: true } },
{ diff: { contextLines: 2 } },
{ diff: { contextLines: 201 } },
{ diff: { changedFilesOnly: "yes" } },
diff --git a/tests/lifecycle.test.ts b/tests/lifecycle.test.ts
index 112e518..26774fd 100644
--- a/tests/lifecycle.test.ts
+++ b/tests/lifecycle.test.ts
@@ -298,6 +298,25 @@ test("startup screenshots are snapshotted, authenticated, and persisted across r
const resumed = await ReviewSession.create(repository, undefined, { ...noAdapters, stateFile, resume: true });
expect(resumed.snapshot().screenshots).toEqual(session.snapshot().screenshots);
+ expect(resumed.snapshot().mediaIncludeChangedImages).toBe(false);
+});
+
+test("changed-image Media opt-in persists, while legacy sessions default to curated media", async () => {
+ const { repository, stateFile } = await repositoryFixture();
+ await savePersistedSession(stateFile, persistedAttachedSession(repository));
+ const legacy = await ReviewSession.create(repository, undefined, { ...noAdapters, stateFile, resume: true });
+ expect(legacy.snapshot().mediaIncludeChangedImages).toBe(false);
+ await legacy.dispose();
+ const enabled = await ReviewSession.create(repository, undefined, { ...noAdapters, stateFile, resume: true, mediaIncludeChangedImages: true });
+ expect(enabled.snapshot().mediaIncludeChangedImages).toBe(true);
+ await enabled.dispose();
+ expect((await loadPersistedSession(stateFile)).mediaIncludeChangedImages).toBe(true);
+ const resumed = await ReviewSession.create(repository, undefined, { ...noAdapters, stateFile, resume: true });
+ expect(resumed.snapshot().mediaIncludeChangedImages).toBe(true);
+ await resumed.dispose();
+ const saved = JSON.parse(await readFile(stateFile, "utf8"));
+ await writeFile(stateFile, JSON.stringify({ ...saved, mediaIncludeChangedImages: "yes" }));
+ await expect(loadPersistedSession(stateFile)).rejects.toThrow();
});
test("selected diff preferences survive an existing session resume", async () => {
diff --git a/tests/media-cli.test.ts b/tests/media-cli.test.ts
new file mode 100644
index 0000000..8b5dde9
--- /dev/null
+++ b/tests/media-cli.test.ts
@@ -0,0 +1,74 @@
+import { expect, test } from "bun:test";
+import { mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { runProcess } from "../src/process.ts";
+
+test("CLI media defaults, flag/config precedence and resume preserve imported evidence", async () => {
+ const root = await realpath(await mkdtemp(join(tmpdir(), "diffcompass-media-cli-")));
+ const repository = join(root, "repo"), stateRoot = join(root, "state");
+ const sessions = join(stateRoot, "diffcompass", "sessions");
+ const children: ReturnType[] = [];
+ async function start(args: string[]) {
+ const child = Bun.spawn([process.execPath, join(import.meta.dir, "../src/cli.ts"), "--repo", repository, "--base", "HEAD", "--attached", "--no-findings", ...args], {
+ cwd: repository, env: { ...process.env, XDG_STATE_HOME: stateRoot }, stdout: "ignore", stderr: "ignore",
+ });
+ children.push(child);
+ const deadline = Date.now() + 10_000;
+ while (child.exitCode === null && Date.now() < deadline) {
+ for (const name of (await readdir(sessions).catch(() => [])).filter(name => name.endsWith(".json"))) {
+ const path = join(sessions, name);
+ const lock = await readFile(`${path}.lock`, "utf8").then(JSON.parse).catch(() => null);
+ if (lock?.pid !== child.pid || !lock.reviewUrl) continue;
+ const state = JSON.parse(await readFile(path, "utf8"));
+ const response = await fetch(`${new URL(lock.reviewUrl).origin}/api/session`, { headers: { "x-diffcompass-token": state.browserToken } });
+ expect(response.status).toBe(200);
+ return { path, snapshot: await response.json(), stop: async () => {
+ child.kill();
+ const timer = setTimeout(() => child.kill("SIGKILL"), 2_000);
+ try { await child.exited; } finally { clearTimeout(timer); }
+ } };
+ }
+ await Bun.sleep(20);
+ }
+ throw Error("Media CLI fixture did not start");
+ }
+ const config = (enabled: boolean) => writeFile(join(repository, ".diffcompass.json"), JSON.stringify({ media: { includeChangedImages: enabled } }));
+ try {
+ await mkdir(repository);
+ for (const args of [["init", "-b", "main"], ["config", "user.name", "Media test"], ["config", "user.email", "media@example.invalid"]]) await runProcess(["git", ...args], repository);
+ await writeFile(join(repository, "app.ts"), "const version = 1;\n");
+ await runProcess(["git", "add", "."], repository);
+ await runProcess(["git", "commit", "-m", "Initial"], repository);
+ await writeFile(join(repository, "app.ts"), "const version = 2;\n");
+ const media = join(root, "qa.png");
+ await writeFile(media, Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64"));
+ const original = await start(["--media", media]);
+ expect(original.snapshot.mediaIncludeChangedImages).toBe(false);
+ expect(original.snapshot.screenshots).toHaveLength(1);
+ await original.stop();
+ await config(true);
+ const resumed = await start(["--resume", original.path]);
+ expect(resumed.snapshot.mediaIncludeChangedImages).toBe(false); // Saved choice wins over changed config.
+ expect(resumed.snapshot.screenshots).toEqual(original.snapshot.screenshots);
+ await resumed.stop();
+ await config(false);
+ const enabled = await start(["--resume", original.path, "--media-include-changed-images"]);
+ expect(enabled.snapshot.mediaIncludeChangedImages).toBe(true); // Explicit flag wins.
+ expect(enabled.snapshot.screenshots).toEqual(original.snapshot.screenshots);
+ await enabled.stop();
+ const enabledResume = await start(["--resume", original.path]);
+ expect(enabledResume.snapshot.mediaIncludeChangedImages).toBe(true);
+ await enabledResume.stop();
+ await config(true);
+ const configured = await start([]);
+ expect(configured.snapshot.mediaIncludeChangedImages).toBe(true);
+ expect(configured.snapshot.screenshots).toEqual([]);
+ await configured.stop();
+ const help = await runProcess([process.execPath, join(import.meta.dir, "../src/cli.ts"), "pr", "123", "--media-include-changed-images", "--help"], repository);
+ expect(help.stdout).toContain("--media-include-changed-images");
+ } finally {
+ for (const child of children) if (child.exitCode === null) { child.kill("SIGKILL"); await child.exited; }
+ await rm(root, { recursive: true, force: true });
+ }
+}, 30_000);
diff --git a/tests/ui.test.ts b/tests/ui.test.ts
index 14328c5..a86c7f4 100644
--- a/tests/ui.test.ts
+++ b/tests/ui.test.ts
@@ -1743,17 +1743,17 @@ test("images and videos are exposed through an authenticated media tab with an e
readFile(join(root, "public/app.js"), "utf8"),
readFile(join(root, "public/styles.css"), "utf8"),
]);
- expect(html).toContain('id="screenshots-tab" class="tab"');
+ expect(html).toContain('id="screenshots-tab" class="tab hidden"');
expect(html).toContain('id="screenshots-view" class="review-view hidden"');
expect(html).toContain('>Media 0 ');
expect(app).toContain("function changedImageMedia()");
expect(app).toContain("const mediaCount = screenshots.length + changedImages.length");
- expect(app).toContain('"conversations", "screenshots", ...(state.session?.pullRequest');
+ expect(app).toContain('state.session?.mediaIncludeChangedImages !== true');
expect(app).toContain('elements["screenshots-tab-count"].classList.toggle("hidden", mediaCount === 0)');
expect(app).toContain('No media is available for this review.');
expect(app).toContain('"X-DiffCompass-Token": state.apiToken');
- expect(app).toContain('apiBlob(`/api/screenshots/${encodeURIComponent(shot.id)}`)');
- expect(app).toContain('apiBlob(`/api/diff/image?${new URLSearchParams({ path: image.path, side: image.side })}`)');
+ expect(app).toContain('mediaUrl(shot.id, `/api/screenshots/${encodeURIComponent(shot.id)}`)');
+ expect(app).toContain('mediaUrl(image.id, `/api/diff/image?${new URLSearchParams({ path: image.path, side: image.side })}`)');
expect(app).toContain('shot.mimeType === "video/mp4"');
expect(styles).toContain(".screenshot-gallery { display: grid;");
expect(styles).toContain(".media-empty { grid-column: 1 / -1; }");