From 32bee19d73fa2ce6bf4149cfe9c663b68fe01664 Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Tue, 29 Sep 2026 00:04:28 -0500 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20Browse=20and=20accept=20local?= =?UTF-8?q?=20TDD=20screenshots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let agents and humans inspect captures after a run without starting the TDD server. Keep baseline acceptance local and update the report so the selected comparison reflects the new baseline. --- docs/json-output.md | 66 ++++ src/cli.js | 46 +++ src/commands/tdd-screenshots.js | 512 ++++++++++++++++++++++++++++++ tests/cli/tdd-help.test.js | 11 + tests/cli/tdd-screenshots.test.js | 415 ++++++++++++++++++++++++ 5 files changed, 1050 insertions(+) create mode 100644 src/commands/tdd-screenshots.js create mode 100644 tests/cli/tdd-screenshots.test.js diff --git a/docs/json-output.md b/docs/json-output.md index 5702f0e0..4b1a4f12 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -161,6 +161,72 @@ When screenshots are captured, Vizzly also writes local review artifacts under `.vizzly/report/index.html`. Use `contextCommand` when you want a stable follow-up command for local review data. +### `vizzly tdd screenshots` + +`tdd screenshots` reads the latest local comparison report directly from +`.vizzly/`. It works after `tdd run` exits and does not need a TDD server or +cloud credentials. + +```bash +vizzly tdd screenshots list +vizzly tdd screenshots list --page 2 --page-size 20 --status failed +vizzly tdd screenshots latest +vizzly tdd screenshots show 1a2b3c4d5e6f7890 +vizzly tdd screenshots show 1a2b3c4d5e6f7890 --image diff +vizzly tdd screenshots accept 1a2b3c4d5e6f7890 +``` + +The list is ordered as the comparisons were recorded. Use the printed +comparison ID with `show`; a screenshot name also works when it identifies one +comparison. If the same name has multiple browser or viewport variants, use an +ID to select one. + +`latest` prints only the absolute path to the most recently captured current +screenshot. Add a screenshot name to choose the latest variant with that name. +`show` prints comparison details and the current screenshot path by default. +Request `--image diff`, `--image baseline`, or `--image all` to include those +image paths. + +JSON list output includes pagination and the current image path with +availability for each comparison: + +```json +{ + "status": "data", + "data": { + "page": 1, + "pageSize": 20, + "total": 1, + "totalPages": 1, + "hasPrevious": false, + "hasNext": false, + "screenshots": [ + { + "id": "1a2b3c4d5e6f7890", + "name": "button-primary", + "status": "failed", + "diffPercentage": 4.2, + "browser": "chromium", + "viewport": { "width": 1920, "height": 1080 }, + "currentImage": { + "path": "/project/.vizzly/current/button-primary.png", + "exists": true + } + } + ] + } +} +``` + +`accept` copies the selected current screenshot into the matching local +baseline, updates baseline metadata, and marks that report entry as passed. +It does not need a running server or cloud credentials. Use +`vizzly tdd run "" --set-baseline --no-open` to set baselines for every +screenshot captured by a run. + +Open or read the printed local image paths with your image viewer when you want +to inspect a capture or diff. + ### `vizzly tdd start` ```bash diff --git a/src/cli.js b/src/cli.js index bfcc8b2d..e496065b 100644 --- a/src/cli.js +++ b/src/cli.js @@ -63,6 +63,12 @@ import { tddStopCommand, validateTddStartOptions, } from './commands/tdd-daemon.js'; +import { + acceptTddScreenshot, + latestTddScreenshot, + listTddScreenshots, + showTddScreenshot, +} from './commands/tdd-screenshots.js'; import { uploadCommand, validateUploadOptions } from './commands/upload.js'; import { whoamiCommand } from './commands/whoami.js'; import { createPluginServices } from './plugin-api.js'; @@ -744,6 +750,46 @@ tddCmd await tddListCommand(options, globalOptions); }); +let tddScreenshotsCmd = tddCmd + .command('screenshots') + .description('Browse local TDD screenshots without a server'); + +tddScreenshotsCmd + .command('list') + .description('List captured screenshots from the latest local TDD run') + .option('--page ', 'Page number', Number, 1) + .option('--page-size ', 'Screenshots per page (max 100)', Number, 20) + .option('--status ', 'Filter by comparison status') + .action(options => { + listTddScreenshots(options); + }); + +tddScreenshotsCmd + .command('latest [name]') + .description('Print the latest captured screenshot path') + .action(name => { + latestTddScreenshot(name); + }); + +tddScreenshotsCmd + .command('show ') + .description('Inspect a screenshot; print current image by default') + .option( + '--image ', + 'Print image path: current, baseline, diff, or all', + 'current' + ) + .action((idOrName, options) => { + showTddScreenshot(idOrName, options); + }); + +tddScreenshotsCmd + .command('accept ') + .description('Accept a captured screenshot as its new local baseline') + .action(async idOrName => { + await acceptTddScreenshot(idOrName); + }); + // TDD Run - One-off test run with ephemeral server (generates static report) tddCmd .command('run ') diff --git a/src/commands/tdd-screenshots.js b/src/commands/tdd-screenshots.js new file mode 100644 index 00000000..074ecd9f --- /dev/null +++ b/src/commands/tdd-screenshots.js @@ -0,0 +1,512 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { basename, join, resolve } from 'node:path'; +import { generateScreenshotSignature } from '../tdd/core/signature.js'; +import { TddService as defaultTddService } from '../tdd/tdd-service.js'; +import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js'; +import * as output from '../utils/output.js'; +import { safePath } from '../utils/security.js'; + +let DEFAULT_PAGE_SIZE = 20; +let MAX_PAGE_SIZE = 100; +let TDD_SCREENSHOT_STATUSES = new Set([ + 'baseline-created', + 'baseline-updated', + 'error', + 'failed', + 'new', + 'passed', + 'rejected', +]); +let TDD_SCREENSHOT_IMAGES = new Set(['current', 'baseline', 'diff', 'all']); + +function readLocalReport(workingDir) { + let projectDir = resolve(workingDir); + let vizzlyDir = join(projectDir, '.vizzly'); + let reportPath = join(vizzlyDir, 'report-data.json'); + + if (!existsSync(reportPath)) { + return { + error: + `No local TDD screenshots found. Run "vizzly tdd run " ` + + `first. Expected ${reportPath}`, + }; + } + + try { + let report = JSON.parse(readFileSync(reportPath, 'utf8')); + if (!report || !Array.isArray(report.comparisons)) { + return { error: `Local TDD report has no comparisons: ${reportPath}` }; + } + if ( + report.comparisons.some( + comparison => + !comparison || + typeof comparison !== 'object' || + Array.isArray(comparison) + ) + ) { + return { + error: `Local TDD report has invalid comparisons: ${reportPath}`, + }; + } + + return { report, projectDir, vizzlyDir }; + } catch (error) { + return { + error: `Could not read local TDD report ${reportPath}: ${error.message}`, + }; + } +} + +function resolveImagePath(imageUrl, vizzlyDir) { + if (typeof imageUrl !== 'string' || imageUrl.length === 0) return null; + + let relativePath; + if (imageUrl.startsWith('/images/')) { + relativePath = imageUrl.slice('/images/'.length); + } else if (imageUrl.startsWith('.vizzly/')) { + relativePath = imageUrl.slice('.vizzly/'.length); + } else { + return null; + } + + try { + return safePath(vizzlyDir, relativePath); + } catch { + return null; + } +} + +function readComparisonDetails(vizzlyDir) { + let detailsPath = join(vizzlyDir, 'comparison-details.json'); + if (!existsSync(detailsPath)) return {}; + + try { + let details = JSON.parse(readFileSync(detailsPath, 'utf8')); + return details && typeof details === 'object' && !Array.isArray(details) + ? details + : {}; + } catch { + return {}; + } +} + +function getViewport(properties) { + return { + width: properties?.viewport_width ?? properties?.viewport?.width ?? null, + height: properties?.viewport_height ?? properties?.viewport?.height ?? null, + }; +} + +function comparisonSummary(comparison, vizzlyDir) { + let currentPath = resolveImagePath(comparison.current, vizzlyDir); + return { + id: comparison.id, + name: comparison.name, + status: comparison.status, + diffPercentage: comparison.diffPercentage ?? null, + browser: comparison.properties?.browser ?? null, + viewport: getViewport(comparison.properties), + currentImage: currentPath + ? { path: currentPath, exists: existsSync(currentPath) } + : null, + }; +} + +function getPaginationOptions(options = {}) { + let page = options.page ?? 1; + let pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE; + let errors = []; + + if (!Number.isInteger(page) || page < 1) { + errors.push('--page must be a positive integer'); + } + if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > MAX_PAGE_SIZE) { + errors.push( + `--page-size must be an integer between 1 and ${MAX_PAGE_SIZE}` + ); + } + if (options.status && !TDD_SCREENSHOT_STATUSES.has(options.status)) { + errors.push( + `--status must be one of: ${[...TDD_SCREENSHOT_STATUSES].join(', ')}` + ); + } + + return { page, pageSize, errors }; +} + +function reportError(message, commandOutput = output) { + commandOutput.error(message); + process.exitCode = 1; +} + +function getImageKinds(image = 'current') { + if (!TDD_SCREENSHOT_IMAGES.has(image)) { + return { + error: `--image must be one of: ${[...TDD_SCREENSHOT_IMAGES].join(', ')}`, + }; + } + + return { kinds: image === 'all' ? ['current', 'baseline', 'diff'] : [image] }; +} + +function imageReferences(comparison, vizzlyDir, kinds) { + return Object.fromEntries( + kinds.map(kind => { + let path = resolveImagePath(comparison[kind], vizzlyDir); + return [kind, path ? { path, exists: existsSync(path) } : null]; + }) + ); +} + +function readLocalComparison(idOrName, workingDir) { + if (!idOrName?.trim()) { + return { error: 'A screenshot name or comparison ID is required.' }; + } + + let local = readLocalReport(workingDir); + if (local.error) return local; + + let found = findComparison(local.report.comparisons, idOrName); + if (found.error) return found; + + return { local, comparison: found.comparison }; +} + +export function listTddScreenshots(options = {}, workingDir = process.cwd()) { + let { page, pageSize, errors } = getPaginationOptions(options); + if (errors.length > 0) { + reportError(errors.join('\n')); + return null; + } + + let local = readLocalReport(workingDir); + if (local.error) { + reportError(local.error); + return null; + } + + let comparisons = options.status + ? local.report.comparisons.filter(item => item.status === options.status) + : local.report.comparisons; + let total = comparisons.length; + let totalPages = Math.ceil(total / pageSize); + let start = (page - 1) * pageSize; + let items = comparisons + .slice(start, start + pageSize) + .map(comparison => comparisonSummary(comparison, local.vizzlyDir)); + let data = { + page, + pageSize, + total, + totalPages, + hasPrevious: page > 1 && total > 0, + hasNext: start + items.length < total, + screenshots: items, + }; + + if (output.isJson()) { + output.data(data); + return data; + } + + let pageLabel = totalPages ? ` of ${totalPages}` : ''; + output.print( + `TDD screenshots · ${total} comparison${total === 1 ? '' : 's'} · ` + + `page ${page}${pageLabel}` + ); + if (options.status) output.print(`Filtered by status: ${options.status}`); + + if (items.length === 0) { + output.print('No screenshots on this page.'); + } + + for (let [index, item] of items.entries()) { + let row = start + index + 1; + let diff = !Number.isFinite(item.diffPercentage) + ? '' + : ` · ${item.diffPercentage.toFixed(2)}% diff`; + output.print(`${row}. ${item.status} · ${item.name} · ${item.id}${diff}`); + } + + if (data.hasNext) { + output.blank(); + let nextPageCommand = + `Next page: vizzly tdd screenshots list --page ${page + 1} ` + + `--page-size ${pageSize}`; + if (options.status) nextPageCommand += ` --status ${options.status}`; + output.print(nextPageCommand); + } + + if (items.length > 0) { + output.blank(); + output.print('Inspect one: vizzly tdd screenshots show '); + } + + return data; +} + +export function latestTddScreenshot(name = null, workingDir = process.cwd()) { + let local = readLocalReport(workingDir); + if (local.error) { + reportError(local.error); + return null; + } + + let matches = local.report.comparisons + .map((comparison, index) => ({ comparison, index })) + .filter(({ comparison }) => !name || comparison.name === name) + .sort((left, right) => { + let leftTimestamp = Number(left.comparison.timestamp) || 0; + let rightTimestamp = Number(right.comparison.timestamp) || 0; + return rightTimestamp - leftTimestamp || right.index - left.index; + }); + + if (matches.length === 0) { + reportError( + name + ? `No local TDD screenshot found for "${name}".` + : 'No local TDD screenshots were captured.' + ); + return null; + } + + let { comparison } = matches[0]; + let image = imageReferences(comparison, local.vizzlyDir, ['current']).current; + let data = { + id: comparison.id, + name: comparison.name, + status: comparison.status, + timestamp: comparison.timestamp ?? null, + image, + }; + + if (!image?.exists) { + reportError(`The latest screenshot is missing for "${comparison.name}".`); + return null; + } + + if (output.isJson()) { + output.data(data); + return data; + } + + output.print(image.path); + return data; +} + +function findComparison(comparisons, idOrName) { + let byId = comparisons.find(item => item.id === idOrName); + if (byId) return { comparison: byId }; + + let bySignature = comparisons.find(item => item.signature === idOrName); + if (bySignature) return { comparison: bySignature }; + + let byName = comparisons.filter(item => item.name === idOrName); + if (byName.length === 1) return { comparison: byName[0] }; + if (byName.length > 1) { + return { + error: + `More than one screenshot is named "${idOrName}". ` + + `Use a comparison ID: ${byName.map(item => item.id).join(', ')}`, + }; + } + + let prefixMatches = comparisons.filter(item => + String(item.id || '').startsWith(idOrName) + ); + if (prefixMatches.length === 1) return { comparison: prefixMatches[0] }; + if (prefixMatches.length > 1) { + return { + error: `Comparison ID prefix "${idOrName}" is ambiguous. Use a full ID.`, + }; + } + + return { error: `No local TDD screenshot found for "${idOrName}".` }; +} + +export function showTddScreenshot( + idOrName, + options = {}, + workingDir = process.cwd() +) { + let selected = readLocalComparison(idOrName, workingDir); + if (selected.error) { + reportError(selected.error); + return null; + } + + let { kinds, error } = getImageKinds(options.image); + if (error) { + reportError(error); + return null; + } + + let { local, comparison } = selected; + let comparisonFacts = { ...comparison }; + delete comparisonFacts.baseline; + delete comparisonFacts.current; + delete comparisonFacts.diff; + let detail = { + comparison: comparisonFacts, + images: imageReferences(comparison, local.vizzlyDir, kinds), + details: readComparisonDetails(local.vizzlyDir)[comparison.id] || {}, + }; + + if (output.isJson()) { + output.data(detail); + return detail; + } + + let { properties = {} } = comparison; + let viewport = getViewport(properties); + output.print(`${comparison.name} · ${comparison.status}`); + output.print(`ID: ${comparison.id}`); + if (comparison.signature) output.print(`Signature: ${comparison.signature}`); + if (Number.isFinite(comparison.diffPercentage)) { + output.print(`Difference: ${comparison.diffPercentage.toFixed(2)}%`); + } + if (properties.browser) output.print(`Browser: ${properties.browser}`); + if (viewport.width && viewport.height) { + output.print(`Viewport: ${viewport.width}×${viewport.height}`); + } + if (comparison.error) output.print(`Error: ${comparison.error}`); + if (comparison.reason) output.print(`Reason: ${comparison.reason}`); + + for (let kind of kinds) { + let image = detail.images[kind]; + let imageLabel = image + ? `${image.path}${image.exists ? '' : ' (missing)'}` + : 'not available'; + output.print(`${kind}: ${imageLabel}`); + } + + return detail; +} + +function removeComparisonDetails(comparison, vizzlyDir, writeFile) { + let detailsPath = join(vizzlyDir, 'comparison-details.json'); + if (!existsSync(detailsPath)) return; + + let details; + try { + details = JSON.parse(readFileSync(detailsPath, 'utf8')); + } catch { + return; + } + + if ( + !details || + typeof details !== 'object' || + Array.isArray(details) || + !Object.hasOwn(details, comparison.id) + ) { + return; + } + + delete details[comparison.id]; + writeFile(detailsPath, JSON.stringify(details)); +} + +function updateReportAfterAccept(report, comparison, baselinePath) { + let timestamp = Date.now(); + let updatedComparison = { + ...comparison, + status: 'passed', + baseline: baselinePath + ? `/images/baselines/${basename(baselinePath)}` + : comparison.baseline, + diffPercentage: 0, + diff: null, + }; + let comparisons = report.comparisons.map(item => + item.id === comparison.id + ? { + ...updatedComparison, + initialStatus: item.initialStatus || item.status, + } + : item + ); + + return { + ...report, + timestamp, + comparisons, + summary: { + total: comparisons.length, + passed: comparisons.filter(item => + ['passed', 'baseline-created', 'new'].includes(item.status) + ).length, + failed: comparisons.filter(item => item.status === 'failed').length, + rejected: comparisons.filter(item => item.status === 'rejected').length, + errors: comparisons.filter(item => item.status === 'error').length, + }, + }; +} + +export async function acceptTddScreenshot( + idOrName, + workingDir = process.cwd(), + deps = {} +) { + let { + loadConfig = defaultLoadConfig, + TddService = defaultTddService, + writeFile = writeFileSync, + output: commandOutput = output, + } = deps; + + let selected = readLocalComparison(idOrName, workingDir); + if (selected.error) { + reportError(selected.error, commandOutput); + return null; + } + + let { local, comparison } = selected; + try { + let config = await loadConfig(); + let service = new TddService(config, local.projectDir); + await service.loadBaseline(); + let accepted = await service.acceptBaseline(comparison); + let signature = generateScreenshotSignature( + comparison.name, + comparison.properties || {}, + service.signatureProperties + ); + let baseline = service.baselineData?.screenshots?.find( + item => item.signature === signature + ); + + writeFile( + join(local.vizzlyDir, 'report-data.json'), + JSON.stringify( + updateReportAfterAccept(local.report, comparison, baseline?.path) + ) + ); + removeComparisonDetails(comparison, local.vizzlyDir, writeFile); + + let data = { + id: comparison.id, + name: comparison.name, + status: accepted.status, + baseline: baseline?.path ?? null, + }; + if (commandOutput.isJson()) { + commandOutput.data(data); + } else { + commandOutput.success( + `Accepted ${comparison.name} as the local baseline`, + { + baseline: data.baseline, + } + ); + } + return data; + } catch (error) { + reportError( + `Failed to accept ${comparison.name}: ${error.message}`, + commandOutput + ); + return null; + } +} diff --git a/tests/cli/tdd-help.test.js b/tests/cli/tdd-help.test.js index da685422..98607069 100644 --- a/tests/cli/tdd-help.test.js +++ b/tests/cli/tdd-help.test.js @@ -18,6 +18,17 @@ describe('cli/tdd help', () => { assert.doesNotMatch(result.stdout, /daemon-child/); }); + it('documents local TDD screenshot browsing commands', async () => { + let result = await runCLI(['--no-color', 'tdd', 'screenshots', '--help']); + + assert.strictEqual(result.code, 0); + assert.match(result.stdout, /vizzly tdd screenshots/); + assert.match(result.stdout, /list/); + assert.match(result.stdout, /latest/); + assert.match(result.stdout, /show/); + assert.match(result.stdout, /accept/); + }); + it('documents port flags for lifecycle commands', async () => { let stop = await runCLI(['--no-color', 'tdd', 'stop', '--help']); let status = await runCLI(['--no-color', 'tdd', 'status', '--help']); diff --git a/tests/cli/tdd-screenshots.test.js b/tests/cli/tdd-screenshots.test.js new file mode 100644 index 00000000..c03b134b --- /dev/null +++ b/tests/cli/tdd-screenshots.test.js @@ -0,0 +1,415 @@ +import assert from 'node:assert/strict'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import { + generateBaselineFilename, + generateComparisonId, + generateScreenshotSignature, +} from '../../src/tdd/core/signature.js'; +import { runCLI } from '../helpers/cli-runner.js'; + +let samplePng = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p3sAAAAASUVORK5CYII=', + 'base64' +); + +function createComparison(name, properties, timestamp, status = 'failed') { + let signature = generateScreenshotSignature(name, properties); + let filename = generateBaselineFilename(name, signature); + return { + id: generateComparisonId(signature), + name, + status, + signature, + properties, + timestamp, + baseline: `/images/baselines/${filename}`, + current: `/images/current/${filename}`, + diff: `/images/diffs/${filename}`, + diffPercentage: 4.2, + threshold: 2, + }; +} + +function createWorkspace() { + let root = realpathSync( + mkdtempSync(join(tmpdir(), 'vizzly-tdd-screenshots-')) + ); + let vizzlyDir = join(root, '.vizzly'); + let baselineDir = join(vizzlyDir, 'baselines'); + let currentDir = join(vizzlyDir, 'current'); + let diffDir = join(vizzlyDir, 'diffs'); + for (let directory of [baselineDir, currentDir, diffDir]) { + mkdirSync(directory, { recursive: true }); + } + + let primary = createComparison( + 'button-primary', + { + browser: 'chromium', + viewport_width: 1280, + viewport_height: 720, + }, + 1000 + ); + let mobileVariant = createComparison( + 'button-primary', + { + browser: 'chromium', + viewport_width: 375, + viewport_height: 812, + }, + 2000 + ); + let settings = createComparison( + 'settings-page', + { + browser: 'firefox', + viewport_width: 1440, + viewport_height: 900, + }, + 1500, + 'new' + ); + + for (let comparison of [primary, mobileVariant, settings]) { + let filename = comparison.current.split('/').at(-1); + writeFileSync(join(currentDir, filename), samplePng); + writeFileSync(join(diffDir, filename), samplePng); + } + writeFileSync( + join(baselineDir, primary.baseline.split('/').at(-1)), + samplePng + ); + writeFileSync( + join(baselineDir, mobileVariant.baseline.split('/').at(-1)), + samplePng + ); + let otherBaseline = { + name: 'existing-baseline', + signature: 'existing-baseline|||', + path: join(baselineDir, 'existing-baseline.png'), + }; + writeFileSync(otherBaseline.path, samplePng); + writeFileSync( + join(baselineDir, 'metadata.json'), + JSON.stringify({ + buildId: 'local-baseline', + buildName: 'Local TDD Baseline', + threshold: 2, + signatureProperties: [], + screenshots: [ + { + name: primary.name, + properties: primary.properties, + path: join(baselineDir, primary.baseline.split('/').at(-1)), + signature: primary.signature, + }, + { + name: mobileVariant.name, + properties: mobileVariant.properties, + path: join(baselineDir, mobileVariant.baseline.split('/').at(-1)), + signature: mobileVariant.signature, + }, + otherBaseline, + ], + }) + ); + writeFileSync( + join(vizzlyDir, 'report-data.json'), + JSON.stringify({ + timestamp: 2000, + comparisons: [primary, mobileVariant, settings], + summary: { total: 3, passed: 0, failed: 2, errors: 0 }, + }) + ); + writeFileSync( + join(vizzlyDir, 'comparison-details.json'), + JSON.stringify({ + [primary.id]: { diffClusters: [{ pixelCount: 12 }] }, + [mobileVariant.id]: { diffClusters: [{ pixelCount: 3 }] }, + }) + ); + + return { + root, + primary, + mobileVariant, + settings, + otherBaseline, + dispose: () => rmSync(root, { recursive: true, force: true }), + }; +} + +function readCommandData(stdout) { + let result = JSON.parse(stdout); + assert.equal(result.status, 'data'); + return result.data; +} + +async function withWorkspace(callback) { + let workspace = createWorkspace(); + try { + return await callback(workspace); + } finally { + workspace.dispose(); + } +} + +describe('cli/tdd screenshots', () => { + it('pages local captures and prints the latest current image path', async () => { + await withWorkspace(async workspace => { + let page = await runCLI( + [ + '--no-color', + 'tdd', + 'screenshots', + 'list', + '--page', + '2', + '--page-size', + '1', + '--json', + ], + { cwd: workspace.root } + ); + assert.equal(page.code, 0, page.stderr); + let data = readCommandData(page.stdout); + assert.equal(data.total, 3); + assert.equal(data.page, 2); + assert.equal(data.screenshots[0].id, workspace.mobileVariant.id); + + let human = await runCLI( + [ + '--no-color', + 'tdd', + 'screenshots', + 'list', + '--page', + '1', + '--page-size', + '1', + ], + { cwd: workspace.root } + ); + assert.equal(human.code, 0, human.stderr); + assert.match(human.stdout, /button-primary/); + assert.match( + human.stdout, + /Next page: vizzly tdd screenshots list --page 2 --page-size 1/ + ); + + let latest = await runCLI( + ['--no-color', 'tdd', 'screenshots', 'latest'], + { cwd: workspace.root } + ); + assert.equal(latest.code, 0, latest.stderr); + assert.equal( + latest.stdout, + join( + workspace.root, + '.vizzly', + 'current', + workspace.mobileVariant.current.split('/').at(-1) + ) + ); + let latestNamed = await runCLI( + ['--no-color', 'tdd', 'screenshots', 'latest', 'settings-page'], + { cwd: workspace.root } + ); + assert.equal(latestNamed.code, 0, latestNamed.stderr); + assert.equal( + latestNamed.stdout, + join( + workspace.root, + '.vizzly', + 'current', + workspace.settings.current.split('/').at(-1) + ) + ); + }); + }); + + it('shows only the current image by default and a diff on request', async () => { + await withWorkspace(async workspace => { + let current = await runCLI( + [ + '--no-color', + 'tdd', + 'screenshots', + 'show', + workspace.primary.id, + '--json', + ], + { cwd: workspace.root } + ); + assert.equal(current.code, 0, current.stderr); + let currentData = readCommandData(current.stdout); + assert.deepEqual(Object.keys(currentData.images), ['current']); + assert.equal(currentData.images.current.exists, true); + assert.equal(currentData.details.diffClusters[0].pixelCount, 12); + + let diff = await runCLI( + [ + '--no-color', + 'tdd', + 'screenshots', + 'show', + workspace.primary.id, + '--image', + 'diff', + '--json', + ], + { cwd: workspace.root } + ); + assert.equal(diff.code, 0, diff.stderr); + let diffData = readCommandData(diff.stdout); + assert.deepEqual(Object.keys(diffData.images), ['diff']); + assert.equal(diffData.images.diff.exists, true); + + let all = await runCLI( + [ + '--no-color', + 'tdd', + 'screenshots', + 'show', + workspace.primary.id, + '--image', + 'all', + '--json', + ], + { cwd: workspace.root } + ); + assert.equal(all.code, 0, all.stderr); + let allImages = readCommandData(all.stdout).images; + assert.deepEqual(Object.keys(allImages), ['current', 'baseline', 'diff']); + assert.equal( + Object.values(allImages).every(image => image.exists), + true + ); + + let ambiguous = await runCLI( + ['--no-color', 'tdd', 'screenshots', 'show', 'button-primary'], + { cwd: workspace.root } + ); + assert.equal(ambiguous.code, 1); + assert.match(ambiguous.stderr, /More than one screenshot is named/); + }); + }); + + it('accepts one capture as baseline and preserves other local baselines', async () => { + await withWorkspace(async workspace => { + let accepted = await runCLI( + [ + '--no-color', + 'tdd', + 'screenshots', + 'accept', + workspace.primary.id, + '--json', + ], + { cwd: workspace.root } + ); + assert.equal(accepted.code, 0, accepted.stderr); + let acceptedData = readCommandData(accepted.stdout); + assert.equal(acceptedData.status, 'accepted'); + assert.equal(existsSync(acceptedData.baseline), true); + assert.deepEqual(readFileSync(acceptedData.baseline), samplePng); + + let report = JSON.parse( + readFileSync( + join(workspace.root, '.vizzly', 'report-data.json'), + 'utf8' + ) + ); + let updated = report.comparisons.find( + item => item.id === workspace.primary.id + ); + assert.equal(updated.status, 'passed'); + assert.equal(updated.diff, null); + assert.equal(report.summary.passed, 2); + + let metadata = JSON.parse( + readFileSync( + join(workspace.root, '.vizzly', 'baselines', 'metadata.json'), + 'utf8' + ) + ); + assert.equal( + metadata.screenshots.some( + item => item.signature === workspace.otherBaseline.signature + ), + true + ); + + let details = JSON.parse( + readFileSync( + join(workspace.root, '.vizzly', 'comparison-details.json'), + 'utf8' + ) + ); + assert.equal(details[workspace.primary.id], undefined); + assert.equal( + details[workspace.mobileVariant.id].diffClusters[0].pixelCount, + 3 + ); + + let shown = await runCLI( + [ + '--no-color', + 'tdd', + 'screenshots', + 'show', + workspace.primary.id, + '--json', + ], + { cwd: workspace.root } + ); + assert.equal(shown.code, 0, shown.stderr); + assert.deepEqual(readCommandData(shown.stdout).details, {}); + }); + }); + + it('rejects invalid pagination and reports missing local capture data', async () => { + await withWorkspace(async workspace => { + let invalidPage = await runCLI( + ['--no-color', 'tdd', 'screenshots', 'list', '--page', '0'], + { cwd: workspace.root } + ); + assert.equal(invalidPage.code, 1); + assert.match(invalidPage.stderr, /--page must be a positive integer/); + + rmSync(join(workspace.root, '.vizzly', 'report-data.json')); + let missing = await runCLI( + ['--no-color', 'tdd', 'screenshots', 'latest'], + { cwd: workspace.root } + ); + assert.equal(missing.code, 1); + assert.match(missing.stderr, /No local TDD screenshots found/); + + writeFileSync( + join(workspace.root, '.vizzly', 'report-data.json'), + JSON.stringify({ comparisons: [null] }) + ); + let malformed = await runCLI( + ['--no-color', 'tdd', 'screenshots', 'latest'], + { cwd: workspace.root } + ); + assert.equal(malformed.code, 1); + assert.match( + malformed.stderr, + /Local TDD report has invalid comparisons/ + ); + }); + }); +}); From fb41160ee930743929c01452a2074c2507445b02 Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Tue, 29 Sep 2026 00:09:15 -0500 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=93=9D=20Teach=20Vizzly=20skill=20loc?= =?UTF-8?q?al=20screenshot=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Point agents at captured images after a TDD run exits. Keep current-image iteration lightweight and make local baseline acceptance explicitly authorized. --- skills/vizzly/references/cli-context.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/skills/vizzly/references/cli-context.md b/skills/vizzly/references/cli-context.md index 27154b7e..a6b22995 100644 --- a/skills/vizzly/references/cli-context.md +++ b/skills/vizzly/references/cli-context.md @@ -85,6 +85,31 @@ vizzly tdd stop --json `tdd run` and `tdd start` are alternatives. Stop only a daemon started for the current task. +## Review A Local TDD Run + +After `tdd run` exits, inspect captures directly from `.vizzly`. These +commands don't need a running TDD daemon or cloud credentials: + +```bash +vizzly tdd screenshots list --page 1 --page-size 20 +vizzly tdd screenshots latest +vizzly tdd screenshots show +``` + +`latest` prints the current screenshot path for the most recent capture. Use +`list` to find a comparison ID when you need a specific screenshot or browser +variant. During UI iteration, inspect the current image first; request +`--image diff` or `--image all` when comparison diagnostics help answer the +question. Open the printed image paths with the available image viewer. + +`accept` replaces one local baseline and updates that report entry. Use it only +when the task explicitly authorizes accepting a baseline; don't accept a change +just to make a diff disappear: + +```bash +vizzly tdd screenshots accept +``` + When a cloud build is in scope: ```bash