diff --git a/README.md b/README.md index 12b5a96..2c79033 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,33 @@ export VIZZLY_TOKEN=your-project-token vizzly run "npm test" --wait ``` +### Visual Context For Agents + +Use `vizzly context` when you want Vizzly to act more like visual context than a test runner. + +This is especially useful for LLM agents, automation, and quick debugging loops. Instead of +making a bunch of narrow API calls, you can ask for one build, comparison, screenshot, or review +queue bundle and get the evidence in one place. + +```bash +# Cloud context for a build or comparison +vizzly context build abc123 +vizzly context comparison def456 --json + +# Local workspace context from .vizzly/ +vizzly context build current --source local +vizzly context screenshot build-detail-screenshots --source local --json +``` + +`--json` is the main automation path. Human-readable output is there for quick terminal use, but +JSON is what you want for scripts, agents, and prompt assembly. + +Local context is read-only and file-backed. It reads your existing `.vizzly` workspace state from +TDD runs, including screenshots, diffs, and any saved hotspot or region metadata. + +Cloud context is also read-only right now. That is intentional. It keeps the trust model simple: +Vizzly helps you see and inspect visual changes, while people still decide what gets approved. + ## Capture Screenshots Add screenshots to your existing tests: @@ -116,6 +143,7 @@ export default { | `vizzly tdd start` | Start local TDD server with dashboard | | `vizzly tdd run "cmd"` | Run tests once, generate static report | | `vizzly run "cmd"` | Run tests with cloud integration | +| `vizzly context ...` | Fetch visual context bundles for builds, comparisons, and screenshots | | `vizzly upload ` | Upload existing screenshots | | `vizzly login` | Authenticate via browser | | `vizzly doctor` | Validate local setup | diff --git a/docs/json-output.md b/docs/json-output.md index b703b3e..3ea95f9 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -190,6 +190,166 @@ vizzly tdd list --json } ``` +### `vizzly context` + +Use `vizzly context` when you want one machine-friendly bundle instead of several narrow calls. +This is the best fit for automation, agents, and scripts that need visual evidence plus a little +bit of memory. + +Every context payload includes a `source` field. That tells you whether the bundle came from +cloud data or your local `.vizzly` workspace. + +#### `vizzly context build` + +```bash +vizzly context build abc123 --json +vizzly context build current --source local --json +``` + +```json +{ + "resource": "build_context", + "source": "cloud", + "scope": { + "organization": { "slug": "acme" }, + "project": { "slug": "storybook" } + }, + "build": { + "id": "abc123", + "status": "completed", + "approval_status": "pending" + }, + "summary": { + "comparisons": { + "total": 12, + "changed": 2, + "new": 1 + }, + "review": { + "pending": 3, + "approved": 9, + "rejected": 0 + } + }, + "comparisons": [ + { + "id": "cmp-1", + "name": "Dashboard", + "result": "changed", + "diff_percentage": 0.42 + } + ] +} +``` + +#### `vizzly context comparison` + +```bash +vizzly context comparison cmp-1 --json +vizzly context comparison build-detail-screenshots --source local --json +``` + +```json +{ + "resource": "comparison_context", + "source": "local_workspace", + "comparison": { + "id": "cmp-1", + "name": "Dashboard", + "result": "changed", + "analysis": { + "diff_image_url": ".vizzly/diffs/dashboard.png", + "diff_regions": [], + "confirmed_regions": [] + } + }, + "history": { + "similar_by_fingerprint": [], + "recent_by_name": [], + "hotspot_analysis": { + "confidence": "no_data" + } + } +} +``` + +#### `vizzly context screenshot` + +```bash +vizzly context screenshot Dashboard --json +vizzly context screenshot Dashboard --source local --json +``` + +```json +{ + "resource": "screenshot_context", + "source": "cloud", + "screenshot": { + "name": "Dashboard" + }, + "confirmed_regions": [ + { + "label": "Known header copy band" + } + ], + "history": { + "recent_comparisons": [] + } +} +``` + +#### `vizzly context similar` + +```bash +vizzly context similar fp-dashboard --project storybook --org acme --json +``` + +```json +{ + "resource": "fingerprint_context", + "source": "cloud", + "fingerprint": { + "hash": "fp-dashboard" + }, + "matches": [ + { + "comparison_id": "cmp-1", + "build_id": "abc123", + "screenshot_name": "Dashboard" + } + ] +} +``` + +Local workspace similarity is not supported yet. If you point `context similar` at `--source local`, +the CLI returns a clear error instead of pretending the data exists. + +#### `vizzly context review-queue` + +```bash +vizzly context review-queue --project storybook --org acme --json +vizzly context review-queue --source local --json +``` + +```json +{ + "resource": "review_queue_context", + "source": "local_workspace", + "summary": { + "total": 2, + "changed": 1, + "new": 1 + }, + "comparisons": [ + { + "id": "cmp-1", + "name": "Settings Panel", + "result": "changed" + } + ] +} +``` + ### `vizzly builds` List builds with optional filtering: diff --git a/src/api/endpoints.js b/src/api/endpoints.js index 43dd94a..11f1c8e 100644 --- a/src/api/endpoints.js +++ b/src/api/endpoints.js @@ -225,6 +225,85 @@ export async function getComparison(client, comparisonId) { return response.comparison; } +/** + * Get build context bundle for agent and reviewer workflows + * @param {Object} client - API client + * @param {string} buildId - Build ID + * @returns {Promise} Build context bundle + */ +export async function getBuildContext(client, buildId) { + return client.request(`/api/sdk/context/builds/${buildId}`); +} + +/** + * Get comparison context bundle with similarity and history + * @param {Object} client - API client + * @param {string} comparisonId - Comparison ID + * @param {Object} options - Optional query params + * @returns {Promise} Comparison context bundle + */ +export async function getComparisonContext(client, comparisonId, options = {}) { + let endpoint = buildEndpointWithParams( + `/api/sdk/context/comparisons/${comparisonId}`, + options + ); + return client.request(endpoint); +} + +/** + * Get screenshot context bundle for a screenshot name + * @param {Object} client - API client + * @param {string} screenshotName - Screenshot name + * @param {Object} options - Optional query params + * @returns {Promise} Screenshot context bundle + */ +export async function getScreenshotContext( + client, + screenshotName, + options = {} +) { + let encodedName = encodeURIComponent(screenshotName); + let endpoint = buildEndpointWithParams( + `/api/sdk/context/screenshots/${encodedName}`, + options + ); + return client.request(endpoint); +} + +/** + * Get project-scoped similar comparisons for a fingerprint hash + * @param {Object} client - API client + * @param {string} fingerprintHash - Honeydiff fingerprint hash + * @param {Object} options - Optional query params + * @returns {Promise} Fingerprint context bundle + */ +export async function getSimilarFingerprintContext( + client, + fingerprintHash, + options = {} +) { + let encodedFingerprint = encodeURIComponent(fingerprintHash); + let endpoint = buildEndpointWithParams( + `/api/sdk/context/fingerprints/${encodedFingerprint}/similar`, + options + ); + return client.request(endpoint); +} + +/** + * Get pending review queue context for a project + * @param {Object} client - API client + * @param {Object} options - Optional query params + * @returns {Promise} Review queue context bundle + */ +export async function getReviewQueueContext(client, options = {}) { + let endpoint = buildEndpointWithParams( + '/api/sdk/context/review-queue', + options + ); + return client.request(endpoint); +} + /** * Search for comparisons by name * @param {Object} client - API client diff --git a/src/api/index.js b/src/api/index.js index 8dcc942..8758c57 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -43,10 +43,15 @@ export { finalizeParallelBuild, getBatchHotspots, getBuild, + getBuildContext, getBuilds, getComparison, + getComparisonContext, getPreviewInfo, + getReviewQueueContext, + getScreenshotContext, getScreenshotHotspots, + getSimilarFingerprintContext, getTddBaselines, getTokenContext, searchComparisons, diff --git a/src/cli.js b/src/cli.js index 58f5031..2aeb807 100644 --- a/src/cli.js +++ b/src/cli.js @@ -12,6 +12,18 @@ import { validateComparisonsOptions, } from './commands/comparisons.js'; import { configCommand, validateConfigOptions } from './commands/config-cmd.js'; +import { + contextBuildCommand, + contextComparisonCommand, + contextReviewQueueCommand, + contextScreenshotCommand, + contextSimilarCommand, + validateContextBuildOptions, + validateContextComparisonOptions, + validateContextReviewQueueOptions, + validateContextScreenshotOptions, + validateContextSimilarOptions, +} from './commands/context.js'; import { doctorCommand, validateDoctorOptions } from './commands/doctor.js'; import { finalizeCommand, @@ -106,6 +118,7 @@ const formatHelp = (cmd, helper) => { 'tdd', 'upload', 'status', + 'context', 'finalize', 'preview', 'builds', @@ -258,6 +271,59 @@ const formatHelp = (cmd, helper) => { return lines.join('\n'); }; +function extractGlobalOptionsFromArgv(argv, commandNames = null) { + let configPath = null; + let verboseMode = false; + let logLevelArg = null; + let jsonArg = null; + + for (let i = 0; i < argv.length; i++) { + if ((argv[i] === '-c' || argv[i] === '--config') && argv[i + 1]) { + configPath = argv[i + 1]; + } + + if (argv[i] === '-v' || argv[i] === '--verbose') { + verboseMode = true; + } + + if (argv[i] === '--log-level' && argv[i + 1]) { + logLevelArg = argv[i + 1]; + } + + if (argv[i] === '--json') { + let nextArg = argv[i + 1]; + let nextArgIsCommand = commandNames?.has(nextArg); + + if (nextArg && !nextArg.startsWith('-') && !nextArgIsCommand) { + jsonArg = nextArg; + } else { + jsonArg = true; + } + } else if (argv[i].startsWith('--json=')) { + jsonArg = argv[i].substring('--json='.length); + } + } + + return { configPath, verboseMode, logLevelArg, jsonArg }; +} + +function normalizeJsonArgv(argv, commandNames) { + let normalizedArgv = [...argv]; + + for (let i = 0; i < normalizedArgv.length; i++) { + if (normalizedArgv[i] !== '--json') { + continue; + } + + let nextArg = normalizedArgv[i + 1]; + if (nextArg && !nextArg.startsWith('-') && commandNames.has(nextArg)) { + normalizedArgv[i] = '--json=true'; + } + } + + return normalizedArgv; +} + program .name('vizzly') .description('Vizzly CLI for visual regression testing') @@ -283,37 +349,8 @@ program // Load plugins before defining commands // We need to manually parse to get the config option early -let configPath = null; -let verboseMode = false; -let logLevelArg = null; -let jsonArg = null; -for (let i = 0; i < process.argv.length; i++) { - if ( - (process.argv[i] === '-c' || process.argv[i] === '--config') && - process.argv[i + 1] - ) { - configPath = process.argv[i + 1]; - } - if (process.argv[i] === '-v' || process.argv[i] === '--verbose') { - verboseMode = true; - } - if (process.argv[i] === '--log-level' && process.argv[i + 1]) { - logLevelArg = process.argv[i + 1]; - } - // Handle --json with optional field selection - // --json (no value) = true, --json=fields or --json fields = "fields" - if (process.argv[i] === '--json') { - let nextArg = process.argv[i + 1]; - // If next arg exists and doesn't start with -, it's the fields value - if (nextArg && !nextArg.startsWith('-')) { - jsonArg = nextArg; - } else { - jsonArg = true; - } - } else if (process.argv[i].startsWith('--json=')) { - jsonArg = process.argv[i].substring('--json='.length); - } -} +let { configPath, verboseMode, logLevelArg, jsonArg } = + extractGlobalOptionsFromArgv(process.argv); // Configure output early // Priority: --log-level > --verbose > VIZZLY_LOG_LEVEL env var > default ('info') @@ -711,6 +748,191 @@ Examples: await comparisonsCommand(options, globalOptions); }); +let contextCmd = program + .command('context') + .description('Fetch visual context bundles for agents and reviewers'); + +contextCmd + .command('build') + .description('Fetch a build context bundle') + .argument('', 'Build ID to fetch context for') + .option('--source ', 'Context source: auto, cloud, or local', 'auto') + .addHelpText( + 'after', + ` +Examples: + $ vizzly context build abc123 + $ vizzly context build current --source local + $ vizzly context build abc123 --json + $ vizzly context build abc123 --json build.id,summary.comparisons +` + ) + .action(async (buildId, options) => { + const globalOptions = program.opts(); + const validationErrors = validateContextBuildOptions(options); + if (validationErrors.length > 0) { + output.error('Validation errors:'); + for (let error of validationErrors) { + output.printErr(` - ${error}`); + } + process.exit(1); + } + + await contextBuildCommand(buildId, options, globalOptions); + }); + +contextCmd + .command('comparison') + .description('Fetch a comparison context bundle') + .argument('', 'Comparison ID to fetch context for') + .option('--source ', 'Context source: auto, cloud, or local', 'auto') + .option( + '--similar-limit ', + 'Maximum similar fingerprint matches to return (1-50)', + val => parseInt(val, 10) + ) + .option( + '--recent-limit ', + 'Maximum recent same-name comparisons to return (1-50)', + val => parseInt(val, 10) + ) + .option( + '--window-size ', + 'Historical hotspot analysis window size (1-50)', + val => parseInt(val, 10) + ) + .addHelpText( + 'after', + ` +Examples: + $ vizzly context comparison def456 + $ vizzly context comparison def456 --source local + $ vizzly context comparison def456 --similar-limit 5 --recent-limit 5 + $ vizzly context comparison def456 --json +` + ) + .action(async (comparisonId, options) => { + const globalOptions = program.opts(); + const validationErrors = validateContextComparisonOptions(options); + if (validationErrors.length > 0) { + output.error('Validation errors:'); + for (let error of validationErrors) { + output.printErr(` - ${error}`); + } + process.exit(1); + } + + await contextComparisonCommand(comparisonId, options, globalOptions); + }); + +contextCmd + .command('screenshot') + .description('Fetch screenshot context and historical memory') + .argument('', 'Screenshot name') + .option('--source ', 'Context source: auto, cloud, or local', 'auto') + .option('-p, --project ', 'Project scope for user auth lookups') + .option('--org ', 'Organization slug when project slug is ambiguous') + .option( + '--recent-limit ', + 'Maximum recent comparisons to return (1-50)', + val => parseInt(val, 10) + ) + .option( + '--window-size ', + 'Historical hotspot analysis window size (1-50)', + val => parseInt(val, 10) + ) + .addHelpText( + 'after', + ` +Examples: + $ vizzly context screenshot Dashboard + $ vizzly context screenshot Dashboard --source local + $ vizzly context screenshot Dashboard --project storybook --org acme + $ vizzly context screenshot Dashboard --json +` + ) + .action(async (name, options) => { + const globalOptions = program.opts(); + const validationErrors = validateContextScreenshotOptions(options); + if (validationErrors.length > 0) { + output.error('Validation errors:'); + for (let error of validationErrors) { + output.printErr(` - ${error}`); + } + process.exit(1); + } + + await contextScreenshotCommand(name, options, globalOptions); + }); + +contextCmd + .command('similar') + .description('Fetch project-scoped matches for a fingerprint hash') + .argument('', 'Fingerprint hash to search for') + .option('--source ', 'Context source: auto, cloud, or local', 'auto') + .option('-p, --project ', 'Project scope for user auth lookups') + .option('--org ', 'Organization slug when project slug is ambiguous') + .option('--limit ', 'Maximum matches to return (1-50)', val => + parseInt(val, 10) + ) + .addHelpText( + 'after', + ` +Examples: + $ vizzly context similar fp-dashboard + $ vizzly context similar fp-dashboard --project storybook --org acme + $ vizzly context similar fp-dashboard --json +` + ) + .action(async (fingerprintHash, options) => { + const globalOptions = program.opts(); + const validationErrors = validateContextSimilarOptions(options); + if (validationErrors.length > 0) { + output.error('Validation errors:'); + for (let error of validationErrors) { + output.printErr(` - ${error}`); + } + process.exit(1); + } + + await contextSimilarCommand(fingerprintHash, options, globalOptions); + }); + +contextCmd + .command('review-queue') + .description('Fetch pending review context for a project') + .option('--source ', 'Context source: auto, cloud, or local', 'auto') + .option('-p, --project ', 'Project scope for user auth lookups') + .option('--org ', 'Organization slug when project slug is ambiguous') + .option('--limit ', 'Maximum comparisons to return (1-100)', val => + parseInt(val, 10) + ) + .option('--offset ', 'Skip first N comparisons', val => parseInt(val, 10)) + .addHelpText( + 'after', + ` +Examples: + $ vizzly context review-queue --project storybook --org acme + $ vizzly context review-queue --source local + $ vizzly context review-queue --limit 10 + $ vizzly context review-queue --json +` + ) + .action(async options => { + const globalOptions = program.opts(); + const validationErrors = validateContextReviewQueueOptions(options); + if (validationErrors.length > 0) { + output.error('Validation errors:'); + for (let error of validationErrors) { + output.printErr(` - ${error}`); + } + process.exit(1); + } + + await contextReviewQueueCommand(options, globalOptions); + }); + program .command('config') .description('Display current configuration') @@ -1149,4 +1371,19 @@ program // This auto-configures the menubar app so it can find npx/node saveUserPath().catch(() => {}); -program.parse(); +let commandNames = new Set(program.commands.map(command => command.name())); +let normalizedArgv = normalizeJsonArgv(process.argv, commandNames); +let normalizedGlobals = extractGlobalOptionsFromArgv( + normalizedArgv, + commandNames +); + +output.configure({ + logLevel: normalizedGlobals.logLevelArg, + verbose: normalizedGlobals.verboseMode, + color: colorOverride, + json: normalizedGlobals.jsonArg, + resetTimer: false, +}); + +program.parse(normalizedArgv); diff --git a/src/commands/context.js b/src/commands/context.js new file mode 100644 index 0000000..d427c48 --- /dev/null +++ b/src/commands/context.js @@ -0,0 +1,806 @@ +/** + * Context commands - fetch visual context bundles for agents and reviewers + */ + +import { + createApiClient as defaultCreateApiClient, + getBuildContext as defaultGetBuildContext, + getComparisonContext as defaultGetComparisonContext, + getReviewQueueContext as defaultGetReviewQueueContext, + getScreenshotContext as defaultGetScreenshotContext, + getSimilarFingerprintContext as defaultGetSimilarFingerprintContext, +} from '../api/index.js'; +import { createLocalWorkspaceContextProvider as defaultCreateLocalWorkspaceContextProvider } from '../context/local-workspace-provider.js'; +import { resolveContextSource as defaultResolveContextSource } from '../context/provider-resolver.js'; +import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js'; +import * as defaultOutput from '../utils/output.js'; + +function buildAuthErrorMessage() { + return 'API token required. Use --token, set VIZZLY_TOKEN, or run "vizzly login"'; +} + +function buildSourceErrorMessage() { + return '--source must be one of: auto, cloud, local'; +} + +function validateLimitRange(value, flagName, { min = 1, max }) { + if (value == null) { + return []; + } + + if (!Number.isInteger(value) || value < min || value > max) { + return [`${flagName} must be a number between ${min} and ${max}`]; + } + + return []; +} + +function validateOffset(value) { + if (value == null) { + return []; + } + + if (!Number.isInteger(value) || value < 0) { + return ['--offset must be a non-negative number']; + } + + return []; +} + +function validateSourceOption(value) { + if (value == null) { + return []; + } + + if (!['auto', 'cloud', 'local'].includes(value)) { + return [buildSourceErrorMessage()]; + } + + return []; +} + +function validateScopedProjectOptions(options = {}) { + let errors = []; + + if (options.org && !options.project) { + errors.push('--org requires --project'); + } + + return errors; +} + +function createClient(config, createApiClient) { + return createApiClient({ + baseUrl: config.apiUrl, + token: config.apiKey, + command: 'context', + }); +} + +async function loadContextConfig(globalOptions, options, deps) { + let { + loadConfig = defaultLoadConfig, + requireApiKey = true, + output = defaultOutput, + exit = code => process.exit(code), + } = deps; + + let allOptions = { ...globalOptions, ...options }; + let config = await loadConfig(globalOptions.config, allOptions); + + if (requireApiKey && !config.apiKey) { + output.error(buildAuthErrorMessage()); + output.cleanup(); + exit(1); + return null; + } + + return config; +} + +function createCloudContextProvider(config, deps = {}) { + let { + createApiClient = defaultCreateApiClient, + getBuildContext = defaultGetBuildContext, + getComparisonContext = defaultGetComparisonContext, + getScreenshotContext = defaultGetScreenshotContext, + getSimilarFingerprintContext = defaultGetSimilarFingerprintContext, + getReviewQueueContext = defaultGetReviewQueueContext, + } = deps; + let client = createClient(config, createApiClient); + + return { + source: 'cloud', + async getBuildContext(buildId) { + return await getBuildContext(client, buildId); + }, + async getComparisonContext(comparisonId, query) { + return await getComparisonContext(client, comparisonId, query); + }, + async getScreenshotContext(screenshotName, query) { + return await getScreenshotContext(client, screenshotName, query); + }, + async getSimilarFingerprintContext(fingerprintHash, query) { + return await getSimilarFingerprintContext(client, fingerprintHash, query); + }, + async getReviewQueueContext(query) { + return await getReviewQueueContext(client, query); + }, + }; +} + +function buildLocalFingerprintCapabilityError() { + let error = new Error( + 'Local workspace context does not support fingerprint similarity yet. Use --source cloud for this query.' + ); + error.code = 'LOCAL_WORKSPACE_CONTEXT'; + return error; +} + +function shouldExplainLocalSimilarityGap( + requestedSource, + command, + localProvider +) { + return ( + requestedSource === 'auto' && + command === 'similar' && + localProvider.isAvailable() + ); +} + +async function loadContextRuntime( + command, + target, + globalOptions, + options, + deps = {} +) { + let { + createLocalWorkspaceContextProvider = defaultCreateLocalWorkspaceContextProvider, + resolveContextSource = defaultResolveContextSource, + output = defaultOutput, + exit = code => process.exit(code), + } = deps; + + let config = await loadContextConfig(globalOptions, options, { + ...deps, + output, + exit, + requireApiKey: false, + }); + let requestedSource = options.source || 'auto'; + let projectRoot = deps.projectRoot || process.cwd(); + let localProvider = createLocalWorkspaceContextProvider({ projectRoot }); + let source = resolveContextSource( + { + requestedSource, + command, + target, + projectRoot, + }, + { + createLocalWorkspaceContextProvider, + } + ); + + if (source === 'cloud' && !config.apiKey) { + if ( + shouldExplainLocalSimilarityGap(requestedSource, command, localProvider) + ) { + throw buildLocalFingerprintCapabilityError(); + } + + output.error(buildAuthErrorMessage()); + output.cleanup(); + exit(1); + return null; + } + + let provider = + source === 'local' + ? localProvider + : createCloudContextProvider(config, deps); + + return { + config, + source, + provider, + }; +} + +function buildScopeQuery(options = {}, query = {}) { + let scopedQuery = { ...query }; + + if (options.project) { + scopedQuery.project = options.project; + } + + if (options.org) { + scopedQuery.organization = options.org; + } + + return scopedQuery; +} + +function getStatusTone(colors, status) { + if (status === 'changed' || status === 'pending' || status === 'failed') { + return colors.brand.warning; + } + + if ( + status === 'approved' || + status === 'completed' || + status === 'identical' + ) { + return colors.brand.success; + } + + if (status === 'rejected' || status === 'error') { + return colors.brand.error; + } + + return colors.brand.info; +} + +function getComparisonDisplayState(comparison = {}) { + return comparison.result || comparison.status || 'unknown'; +} + +function formatConfirmedRegionLabels(regions = []) { + return regions + .map(region => region.label) + .filter(Boolean) + .slice(0, 3) + .join(' · '); +} + +function printComparisonList(output, comparisons = [], { limit = 5 } = {}) { + let colors = output.getColors(); + + for (let comparison of comparisons.slice(0, limit)) { + let displayState = getComparisonDisplayState(comparison); + let statusTone = getStatusTone(colors, displayState); + let screenshotName = + comparison.screenshot?.name || comparison.name || comparison.id; + let diffPercentage = + comparison.diff_percentage == null + ? null + : `${comparison.diff_percentage}%`; + let fingerprint = comparison.analysis?.fingerprint_hash || null; + let details = []; + + if (diffPercentage) { + details.push(diffPercentage); + } + + if (fingerprint) { + details.push(`fp:${fingerprint}`); + } + + if (comparison.build_branch) { + details.push(comparison.build_branch); + } + + output.print( + ` ${colors.bold(screenshotName)} ${statusTone(displayState.toUpperCase())}` + ); + if (details.length > 0) { + output.print(` ${colors.dim(details.join(' · '))}`); + } + } +} + +function displayBuildContext(output, context) { + output.header('context', 'build'); + + let colors = output.getColors(); + let buildTone = getStatusTone(colors, context.build.status); + + output.print( + ` ${colors.bold(context.build.name || context.build.id)} ${buildTone((context.build.status || 'unknown').toUpperCase())}` + ); + output.print( + ` ${colors.dim(`@${context.scope.organization.slug}/${context.scope.project.slug}`)}` + ); + output.blank(); + + output.labelValue('Comparisons', String(context.comparisons.length)); + output.labelValue( + 'Review', + `${context.summary.review.pending || 0} pending · ${context.summary.review.approved || 0} approved · ${context.summary.review.rejected || 0} rejected` + ); + output.labelValue( + 'Memory', + `${context.review.comments.length} build comments · ${context.review.assignments.length} assignments` + ); + + if (context.preview) { + output.labelValue( + 'Preview', + `${context.preview.status}${context.preview.preview_url ? ' · available' : ''}` + ); + } + + if (context.links?.build_url) { + output.labelValue('Build URL', context.links.build_url); + } + + if (context.comparisons.length > 0) { + output.blank(); + output.print(' Comparisons'); + printComparisonList(output, context.comparisons); + } +} + +function countScreenshotCommentEntries(groups = []) { + return groups.reduce( + (total, group) => total + (group.comments?.length || 0), + 0 + ); +} + +function displayComparisonContext(output, context) { + output.header('context', 'comparison'); + + let colors = output.getColors(); + let displayState = getComparisonDisplayState(context.comparison); + let statusTone = getStatusTone(colors, displayState); + let screenshotName = + context.comparison.screenshot?.name || context.comparison.id; + let analysis = context.comparison.analysis || {}; + let confirmedRegionLabels = formatConfirmedRegionLabels( + context.history.confirmed_regions + ); + + output.print( + ` ${colors.bold(screenshotName)} ${statusTone(displayState.toUpperCase())}` + ); + output.print( + ` ${colors.dim(`@${context.scope.organization.slug}/${context.scope.project.slug}`)}` + ); + output.blank(); + + output.labelValue( + 'Eyes', + `${analysis.diff_image_url ? 'baseline/current/diff' : 'comparison metadata only'}` + ); + output.labelValue( + 'Memory', + `${context.history.similar_by_fingerprint.length} similar · ${context.history.recent_by_name.length} recent · ${context.history.confirmed_regions.length} confirmed regions` + ); + output.labelValue( + 'Review', + `${context.review.build_comments.length} build comments · ${countScreenshotCommentEntries(context.review.screenshot_comments)} screenshot comments` + ); + + if (analysis.fingerprint_hash) { + output.labelValue('Fingerprint', analysis.fingerprint_hash); + } + + if (confirmedRegionLabels) { + output.labelValue('Known Regions', confirmedRegionLabels); + } + + if (context.links?.comparison_url) { + output.labelValue('Comparison URL', context.links.comparison_url); + } + + if (context.history.similar_by_fingerprint.length > 0) { + output.blank(); + output.print(' Similar Diffs'); + printComparisonList(output, context.history.similar_by_fingerprint); + } +} + +function displayScreenshotContext(output, context) { + output.header('context', 'screenshot'); + + let colors = output.getColors(); + let confirmedRegionLabels = formatConfirmedRegionLabels( + context.confirmed_regions + ); + + output.print(` ${colors.bold(context.screenshot.name)}`); + output.print( + ` ${colors.dim(`@${context.scope.organization.slug}/${context.scope.project.slug}`)}` + ); + output.blank(); + + output.labelValue( + 'Memory', + `${context.history.recent_comparisons.length} recent comparisons · ${context.confirmed_regions.length} confirmed regions` + ); + output.labelValue( + 'Hotspots', + `${context.hotspot_analysis.total_builds_analyzed} builds analyzed · ${context.hotspot_analysis.confidence}` + ); + + if (confirmedRegionLabels) { + output.labelValue('Known Regions', confirmedRegionLabels); + } + + if (context.history.recent_comparisons.length > 0) { + output.blank(); + output.print(' Recent Comparisons'); + printComparisonList(output, context.history.recent_comparisons); + } +} + +function displayFingerprintContext(output, context) { + output.header('context', 'similar'); + + let colors = output.getColors(); + let fingerprintHash = + context.fingerprint?.hash || context.fingerprint_hash || 'unknown'; + let comparisons = context.comparisons || context.matches || []; + + output.print(` ${colors.bold(fingerprintHash)}`); + output.print( + ` ${colors.dim(`@${context.scope.organization.slug}/${context.scope.project.slug}`)}` + ); + output.blank(); + + output.labelValue('Matches', String(comparisons.length)); + + if (comparisons.length > 0) { + output.blank(); + output.print(' Similar Diffs'); + printComparisonList(output, comparisons, { limit: 10 }); + } +} + +function displayReviewQueueContext(output, context) { + output.header('context', 'review'); + + let colors = output.getColors(); + + output.print( + ` ${colors.bold(`${context.summary.total} pending comparisons`)}` + ); + output.print( + ` ${colors.dim(`@${context.scope.organization.slug}/${context.scope.project.slug}`)}` + ); + output.blank(); + + output.labelValue( + 'Queue', + `${context.summary.changed} changed · ${context.summary.new} new · ${context.summary.builds} builds` + ); + + if (context.comparisons.length > 0) { + output.blank(); + output.print(' Needs Review'); + printComparisonList(output, context.comparisons, { limit: 10 }); + } +} + +export async function contextBuildCommand( + buildId, + options = {}, + globalOptions = {}, + deps = {} +) { + let { output = defaultOutput, exit = code => process.exit(code) } = deps; + + output.configure({ + json: globalOptions.json, + verbose: globalOptions.verbose, + color: !globalOptions.noColor, + }); + + try { + let runtime = await loadContextRuntime( + 'build', + buildId, + globalOptions, + options, + { + ...deps, + output, + exit, + } + ); + if (!runtime) { + return; + } + + output.startSpinner('Fetching build context...'); + let context = await runtime.provider.getBuildContext(buildId); + output.stopSpinner(); + + if (globalOptions.json) { + output.data(context); + output.cleanup(); + return; + } + + displayBuildContext(output, context); + output.cleanup(); + } catch (error) { + output.stopSpinner(); + output.error('Failed to fetch build context', error); + output.cleanup(); + exit(1); + } +} + +export async function contextComparisonCommand( + comparisonId, + options = {}, + globalOptions = {}, + deps = {} +) { + let { output = defaultOutput, exit = code => process.exit(code) } = deps; + + output.configure({ + json: globalOptions.json, + verbose: globalOptions.verbose, + color: !globalOptions.noColor, + }); + + try { + let runtime = await loadContextRuntime( + 'comparison', + comparisonId, + globalOptions, + options, + { + ...deps, + output, + exit, + } + ); + if (!runtime) { + return; + } + let query = { + similarLimit: options.similarLimit, + recentLimit: options.recentLimit, + windowSize: options.windowSize, + }; + + output.startSpinner('Fetching comparison context...'); + let context = await runtime.provider.getComparisonContext( + comparisonId, + query + ); + output.stopSpinner(); + + if (globalOptions.json) { + output.data(context); + output.cleanup(); + return; + } + + displayComparisonContext(output, context); + output.cleanup(); + } catch (error) { + output.stopSpinner(); + output.error('Failed to fetch comparison context', error); + output.cleanup(); + exit(1); + } +} + +export async function contextScreenshotCommand( + screenshotName, + options = {}, + globalOptions = {}, + deps = {} +) { + let { output = defaultOutput, exit = code => process.exit(code) } = deps; + + output.configure({ + json: globalOptions.json, + verbose: globalOptions.verbose, + color: !globalOptions.noColor, + }); + + try { + let runtime = await loadContextRuntime( + 'screenshot', + screenshotName, + globalOptions, + options, + { + ...deps, + output, + exit, + } + ); + if (!runtime) { + return; + } + let query = buildScopeQuery(options, { + recentLimit: options.recentLimit, + windowSize: options.windowSize, + }); + + output.startSpinner('Fetching screenshot context...'); + let context = await runtime.provider.getScreenshotContext( + screenshotName, + query + ); + output.stopSpinner(); + + if (globalOptions.json) { + output.data(context); + output.cleanup(); + return; + } + + displayScreenshotContext(output, context); + output.cleanup(); + } catch (error) { + output.stopSpinner(); + output.error('Failed to fetch screenshot context', error); + output.cleanup(); + exit(1); + } +} + +export async function contextSimilarCommand( + fingerprintHash, + options = {}, + globalOptions = {}, + deps = {} +) { + let { output = defaultOutput, exit = code => process.exit(code) } = deps; + + output.configure({ + json: globalOptions.json, + verbose: globalOptions.verbose, + color: !globalOptions.noColor, + }); + + try { + let runtime = await loadContextRuntime( + 'similar', + fingerprintHash, + globalOptions, + options, + { + ...deps, + output, + exit, + } + ); + if (!runtime) { + return; + } + let query = buildScopeQuery(options, { + limit: options.limit, + }); + + output.startSpinner('Fetching similar visual context...'); + let context = await runtime.provider.getSimilarFingerprintContext( + fingerprintHash, + query + ); + output.stopSpinner(); + + if (globalOptions.json) { + output.data(context); + output.cleanup(); + return; + } + + displayFingerprintContext(output, context); + output.cleanup(); + } catch (error) { + output.stopSpinner(); + output.error('Failed to fetch similar visual context', error); + output.cleanup(); + exit(1); + } +} + +export async function contextReviewQueueCommand( + options = {}, + globalOptions = {}, + deps = {} +) { + let { output = defaultOutput, exit = code => process.exit(code) } = deps; + + output.configure({ + json: globalOptions.json, + verbose: globalOptions.verbose, + color: !globalOptions.noColor, + }); + + try { + let runtime = await loadContextRuntime( + 'review-queue', + null, + globalOptions, + options, + { + ...deps, + output, + exit, + } + ); + if (!runtime) { + return; + } + let query = buildScopeQuery(options, { + limit: options.limit, + offset: options.offset, + }); + + output.startSpinner('Fetching review queue context...'); + let context = await runtime.provider.getReviewQueueContext(query); + output.stopSpinner(); + + if (globalOptions.json) { + output.data(context); + output.cleanup(); + return; + } + + displayReviewQueueContext(output, context); + output.cleanup(); + } catch (error) { + output.stopSpinner(); + output.error('Failed to fetch review queue context', error); + output.cleanup(); + exit(1); + } +} + +export function validateContextBuildOptions(_options = {}) { + return validateSourceOption(_options.source); +} + +export function validateContextComparisonOptions(options = {}) { + let errors = []; + errors.push(...validateSourceOption(options.source)); + errors.push( + ...validateLimitRange(options.similarLimit, '--similar-limit', { + max: 50, + }) + ); + errors.push( + ...validateLimitRange(options.recentLimit, '--recent-limit', { + max: 50, + }) + ); + errors.push( + ...validateLimitRange(options.windowSize, '--window-size', { + max: 50, + }) + ); + return errors; +} + +export function validateContextScreenshotOptions(options = {}) { + let errors = validateScopedProjectOptions(options); + errors.push(...validateSourceOption(options.source)); + errors.push( + ...validateLimitRange(options.recentLimit, '--recent-limit', { + max: 50, + }) + ); + errors.push( + ...validateLimitRange(options.windowSize, '--window-size', { + max: 50, + }) + ); + return errors; +} + +export function validateContextSimilarOptions(options = {}) { + let errors = validateScopedProjectOptions(options); + errors.push(...validateSourceOption(options.source)); + errors.push(...validateLimitRange(options.limit, '--limit', { max: 50 })); + return errors; +} + +export function validateContextReviewQueueOptions(options = {}) { + let errors = validateScopedProjectOptions(options); + errors.push(...validateSourceOption(options.source)); + errors.push(...validateLimitRange(options.limit, '--limit', { max: 100 })); + errors.push(...validateOffset(options.offset)); + return errors; +} diff --git a/src/context/local-workspace-provider.js b/src/context/local-workspace-provider.js new file mode 100644 index 0000000..38f7a33 --- /dev/null +++ b/src/context/local-workspace-provider.js @@ -0,0 +1,583 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { basename, isAbsolute, join } from 'node:path'; +import { normalizeReportData } from '../utils/report-data.js'; + +let LOCAL_CONTEXT_SOURCE = 'local_workspace'; +let DEFAULT_LOCAL_REVIEW_QUEUE_LIMIT = 50; + +function readJsonIfExists(path) { + if (!existsSync(path)) { + return null; + } + + try { + return JSON.parse(readFileSync(path, 'utf8')); + } catch { + return null; + } +} + +function createEmptyReportData() { + return { + timestamp: Date.now(), + comparisons: [], + summary: { + total: 0, + passed: 0, + failed: 0, + rejected: 0, + errors: 0, + }, + }; +} + +function mapComparisonResult(status) { + if (status === 'new') { + return 'new'; + } + + if (status === 'failed' || status === 'rejected') { + return 'changed'; + } + + if (status === 'passed' || status === 'baseline-created') { + return 'identical'; + } + + if (status === 'error') { + return 'error'; + } + + return status || 'unknown'; +} + +function mapApprovalStatus(status) { + if (status === 'failed' || status === 'new') { + return 'pending'; + } + + if (status === 'rejected') { + return 'rejected'; + } + + if (status === 'passed' || status === 'baseline-created') { + return 'approved'; + } + + return status || 'unknown'; +} + +function buildLocalScope(projectRoot) { + let projectName = basename(projectRoot); + + return { + organization: { + id: null, + name: 'Local Workspace', + slug: 'local', + }, + project: { + id: null, + name: projectName, + slug: projectName, + }, + }; +} + +function resolveAssetReference(assetPath, snapshot) { + if (!assetPath) { + return null; + } + + if (/^https?:\/\//.test(assetPath) || isAbsolute(assetPath)) { + return assetPath; + } + + if (assetPath.startsWith('/images/')) { + if (snapshot.serverInfo?.port) { + return `http://127.0.0.1:${snapshot.serverInfo.port}${assetPath}`; + } + + return join(snapshot.vizzlyDir, assetPath.replace('/images/', '')); + } + + return assetPath; +} + +function normalizeConfirmedRegions(regions = []) { + return regions.map((region, index) => ({ + id: region.id || `local-region-${index}`, + x1: region.x1 ?? region.x ?? null, + y1: region.y1 ?? region.y ?? null, + x2: + region.x2 ?? + (region.x != null && region.width != null + ? region.x + region.width + : null), + y2: + region.y2 ?? + (region.y != null && region.height != null + ? region.y + region.height + : null), + label: region.label || null, + })); +} + +function mergeConfirmedRegions(snapshot, comparisonName, details = {}) { + let workspaceRegions = snapshot.regions?.[comparisonName]?.confirmed || []; + let detailRegions = details.confirmedRegions || []; + let merged = [...workspaceRegions, ...detailRegions]; + let seen = new Set(); + + return normalizeConfirmedRegions(merged).filter(region => { + let key = `${region.label || ''}:${region.x1}:${region.y1}:${region.x2}:${region.y2}`; + + if (seen.has(key)) { + return false; + } + + seen.add(key); + return true; + }); +} + +function buildHotspotAnalysis(snapshot, comparisonName, details = {}) { + let hotspotMetadata = snapshot.hotspots?.[comparisonName] || null; + let hotspotAnalysis = details.hotspotAnalysis || null; + + if (!hotspotMetadata && !hotspotAnalysis) { + return { + regions: [], + total_builds_analyzed: 0, + confidence: 'no_data', + confidence_score: null, + data_source: 'local_workspace', + }; + } + + return { + regions: hotspotMetadata?.regions || [], + total_builds_analyzed: 1, + confidence: + hotspotAnalysis?.confidence || hotspotMetadata?.confidence || 'workspace', + confidence_score: hotspotAnalysis?.confidenceScore || null, + data_source: 'local_workspace', + }; +} + +function buildComparisonLinks(snapshot, comparisonId) { + if (!snapshot.serverInfo?.port) { + return {}; + } + + return { + build_url: `http://127.0.0.1:${snapshot.serverInfo.port}/builds`, + comparison_url: `http://127.0.0.1:${snapshot.serverInfo.port}/comparison/${encodeURIComponent(comparisonId)}`, + }; +} + +function buildBuildSnapshot(snapshot) { + let buildId = + snapshot.session?.buildId || + snapshot.serverInfo?.buildId || + 'local-workspace'; + + return { + id: buildId, + name: buildId, + branch: snapshot.session?.branch || 'local', + commit_sha: snapshot.session?.commit || null, + commit_message: null, + approval_status: snapshot.serverInfo ? 'pending' : 'approved', + status: snapshot.serverInfo ? 'running' : 'completed', + created_at: snapshot.session?.createdAt || null, + }; +} + +function mapLocalComparison(snapshot, comparison) { + let details = snapshot.comparisonDetails[comparison.id] || {}; + let comparisonName = comparison.originalName || comparison.name; + let confirmedRegions = mergeConfirmedRegions( + snapshot, + comparisonName, + details + ); + let hotspotAnalysis = buildHotspotAnalysis(snapshot, comparisonName, details); + let properties = comparison.properties || {}; + let buildSnapshot = buildBuildSnapshot(snapshot); + + return { + id: comparison.id, + name: comparisonName, + status: comparison.status, + result: mapComparisonResult(comparison.status), + approval_status: mapApprovalStatus(comparison.status), + build_id: buildSnapshot.id, + build_name: buildSnapshot.name, + build_branch: buildSnapshot.branch, + build_commit_sha: buildSnapshot.commit_sha, + build_created_at: buildSnapshot.created_at, + threshold: comparison.threshold ?? null, + diff_percentage: comparison.diffPercentage ?? null, + changed_pixels: comparison.diffCount ?? null, + total_pixels: comparison.totalPixels ?? null, + screenshot: { + id: comparison.id, + name: comparisonName, + browser: properties.browser ?? null, + viewport_width: properties.viewport_width ?? null, + viewport_height: properties.viewport_height ?? null, + original_url: resolveAssetReference(comparison.current, snapshot), + }, + baseline: comparison.baseline + ? { + id: `${comparison.id}-baseline`, + name: comparisonName, + browser: properties.browser ?? null, + viewport_width: properties.viewport_width ?? null, + viewport_height: properties.viewport_height ?? null, + original_url: resolveAssetReference(comparison.baseline, snapshot), + } + : null, + analysis: { + diff_image_url: resolveAssetReference(comparison.diff, snapshot), + diff_regions: details.diffClusters || [], + cluster_metadata: details.diffClusters + ? { + clusterCount: details.diffClusters.length, + local_workspace: true, + } + : null, + diff_lines: null, + fingerprint_hash: null, + fingerprint_data: null, + hotspot_analysis: hotspotAnalysis, + region_analysis: details.regionAnalysis || null, + confirmed_regions: confirmedRegions, + }, + }; +} + +function buildReviewSummary(comparisons = []) { + let approved = comparisons.filter( + comparison => mapApprovalStatus(comparison.status) === 'approved' + ).length; + let rejected = comparisons.filter( + comparison => mapApprovalStatus(comparison.status) === 'rejected' + ).length; + let pending = comparisons.filter( + comparison => mapApprovalStatus(comparison.status) === 'pending' + ).length; + + return { + total: comparisons.length, + pending, + approved, + rejected, + }; +} + +function createLocalWorkspaceError(message) { + let error = new Error(message); + error.code = 'LOCAL_WORKSPACE_CONTEXT'; + return error; +} + +export function createLocalWorkspaceContextProvider(options = {}, deps = {}) { + let projectRoot = options.projectRoot || process.cwd(); + let readJson = deps.readJsonIfExists || readJsonIfExists; + let snapshotCache = null; + + function loadSnapshot() { + if (snapshotCache) { + return snapshotCache; + } + + let vizzlyDir = join(projectRoot, '.vizzly'); + snapshotCache = { + projectRoot, + vizzlyDir, + serverInfo: readJson(join(vizzlyDir, 'server.json')), + session: readJson(join(vizzlyDir, 'session.json')), + reportData: normalizeReportData( + readJson(join(vizzlyDir, 'report-data.json')) || createEmptyReportData() + ), + comparisonDetails: + readJson(join(vizzlyDir, 'comparison-details.json')) || {}, + baselineMetadata: readJson(join(vizzlyDir, 'baselines', 'metadata.json')), + hotspotFile: readJson(join(vizzlyDir, 'hotspots.json')), + regionFile: readJson(join(vizzlyDir, 'regions.json')), + }; + + snapshotCache.hotspots = snapshotCache.hotspotFile?.hotspots || null; + snapshotCache.regions = snapshotCache.regionFile?.regions || null; + return snapshotCache; + } + + function isAvailable(snapshot = loadSnapshot()) { + return Boolean( + snapshot.serverInfo || + snapshot.session || + snapshot.reportData.comparisons.length > 0 || + snapshot.baselineMetadata + ); + } + + function findComparison(snapshot, target) { + if (!target) { + return null; + } + + return ( + snapshot.reportData.comparisons.find( + comparison => comparison.id === target + ) || + snapshot.reportData.comparisons.find( + comparison => comparison.signature === target + ) || + snapshot.reportData.comparisons.find( + comparison => (comparison.originalName || comparison.name) === target + ) || + null + ); + } + + function canHandle(command, target, snapshot = loadSnapshot()) { + if (!isAvailable(snapshot)) { + return false; + } + + if (command === 'build') { + let buildId = buildBuildSnapshot(snapshot).id; + return target === 'current' || target === 'local' || target === buildId; + } + + if (command === 'comparison') { + return Boolean(findComparison(snapshot, target)); + } + + if (command === 'screenshot') { + return Boolean( + snapshot.reportData.comparisons.some( + comparison => (comparison.originalName || comparison.name) === target + ) || + snapshot.regions?.[target] || + snapshot.hotspots?.[target] + ); + } + + if (command === 'review-queue') { + return snapshot.reportData.comparisons.length > 0; + } + + if (command === 'similar') { + return false; + } + + return false; + } + + function createScope() { + return buildLocalScope(projectRoot); + } + + function createBuildLinks(snapshot) { + if (!snapshot.serverInfo?.port) { + return {}; + } + + let buildId = buildBuildSnapshot(snapshot).id; + + return { + build_url: `http://127.0.0.1:${snapshot.serverInfo.port}/builds`, + comparison_url_prefix: `http://127.0.0.1:${snapshot.serverInfo.port}/comparison`, + current_build_id: buildId, + }; + } + + function getBuildContext(buildId) { + let snapshot = loadSnapshot(); + let resolvedBuild = buildBuildSnapshot(snapshot); + + if ( + !( + buildId === 'current' || + buildId === 'local' || + buildId === resolvedBuild.id + ) + ) { + throw createLocalWorkspaceError( + `Local workspace context is only available for the active session build (${resolvedBuild.id})` + ); + } + + let mappedComparisons = snapshot.reportData.comparisons.map(comparison => + mapLocalComparison(snapshot, comparison) + ); + let reviewSummary = buildReviewSummary(snapshot.reportData.comparisons); + + return { + resource: 'build_context', + source: LOCAL_CONTEXT_SOURCE, + scope: createScope(), + build: resolvedBuild, + summary: { + comparisons: { + total: mappedComparisons.length, + changed: mappedComparisons.filter( + comparison => comparison.result === 'changed' + ).length, + new: mappedComparisons.filter( + comparison => comparison.result === 'new' + ).length, + }, + review: reviewSummary, + }, + review: { + comments: [], + assignments: [], + }, + comparisons: mappedComparisons, + links: createBuildLinks(snapshot), + }; + } + + function getComparisonContext(comparisonId) { + let snapshot = loadSnapshot(); + let comparison = findComparison(snapshot, comparisonId); + + if (!comparison) { + throw createLocalWorkspaceError( + `No local comparison found for "${comparisonId}"` + ); + } + + let mappedComparison = mapLocalComparison(snapshot, comparison); + let comparisonName = comparison.originalName || comparison.name; + let history = snapshot.reportData.comparisons + .filter( + candidate => + candidate.id !== comparison.id && + (candidate.originalName || candidate.name) === comparisonName + ) + .map(candidate => mapLocalComparison(snapshot, candidate)); + + return { + resource: 'comparison_context', + source: LOCAL_CONTEXT_SOURCE, + scope: createScope(), + build: buildBuildSnapshot(snapshot), + comparison: mappedComparison, + history: { + similar_by_fingerprint: [], + recent_by_name: history, + hotspot_analysis: buildHotspotAnalysis( + snapshot, + comparisonName, + snapshot.comparisonDetails[comparison.id] || {} + ), + confirmed_regions: mergeConfirmedRegions( + snapshot, + comparisonName, + snapshot.comparisonDetails[comparison.id] || {} + ), + }, + review: { + review_summary: { + total: 0, + completed: 0, + pending: 0, + approved: 0, + changes_requested: 0, + commented: 0, + has_changes_requested: false, + decisions: [], + }, + assignments: [], + build_comments: [], + screenshot_comments: [], + }, + links: buildComparisonLinks(snapshot, comparison.id), + }; + } + + function getScreenshotContext(screenshotName) { + let snapshot = loadSnapshot(); + let matches = snapshot.reportData.comparisons + .filter( + comparison => + (comparison.originalName || comparison.name) === screenshotName + ) + .map(comparison => mapLocalComparison(snapshot, comparison)); + + if ( + matches.length === 0 && + !snapshot.regions?.[screenshotName] && + !snapshot.hotspots?.[screenshotName] + ) { + throw createLocalWorkspaceError( + `No local screenshot context found for "${screenshotName}"` + ); + } + + return { + resource: 'screenshot_context', + source: LOCAL_CONTEXT_SOURCE, + scope: createScope(), + screenshot: { + name: screenshotName, + }, + hotspot_analysis: buildHotspotAnalysis(snapshot, screenshotName), + confirmed_regions: mergeConfirmedRegions(snapshot, screenshotName), + history: { + recent_comparisons: matches, + }, + }; + } + + function getReviewQueueContext(query = {}) { + let snapshot = loadSnapshot(); + let unresolved = snapshot.reportData.comparisons.filter( + comparison => + comparison.status === 'failed' || comparison.status === 'new' + ); + let offset = query.offset || 0; + let limit = query.limit || DEFAULT_LOCAL_REVIEW_QUEUE_LIMIT; + let visible = unresolved + .slice(offset, offset + limit) + .map(comparison => mapLocalComparison(snapshot, comparison)); + + return { + resource: 'review_queue_context', + source: LOCAL_CONTEXT_SOURCE, + scope: createScope(), + summary: { + total: unresolved.length, + changed: unresolved.filter(comparison => comparison.status === 'failed') + .length, + new: unresolved.filter(comparison => comparison.status === 'new') + .length, + builds: unresolved.length > 0 ? 1 : 0, + }, + comparisons: visible, + }; + } + + function getSimilarFingerprintContext() { + throw createLocalWorkspaceError( + 'Local workspace context does not support fingerprint similarity yet. Use --source cloud for this query.' + ); + } + + return { + source: LOCAL_CONTEXT_SOURCE, + loadSnapshot, + isAvailable, + canHandle, + getBuildContext, + getComparisonContext, + getScreenshotContext, + getReviewQueueContext, + getSimilarFingerprintContext, + }; +} diff --git a/src/context/provider-resolver.js b/src/context/provider-resolver.js new file mode 100644 index 0000000..57b667d --- /dev/null +++ b/src/context/provider-resolver.js @@ -0,0 +1,50 @@ +import { createLocalWorkspaceContextProvider as defaultCreateLocalWorkspaceContextProvider } from './local-workspace-provider.js'; + +export function resolveContextSource(options = {}, deps = {}) { + let { + requestedSource = 'auto', + command, + target = null, + projectRoot = process.cwd(), + } = options; + let { + createLocalWorkspaceContextProvider = defaultCreateLocalWorkspaceContextProvider, + } = deps; + + if (requestedSource === 'cloud') { + return 'cloud'; + } + + let localProvider = createLocalWorkspaceContextProvider({ projectRoot }); + let localSnapshot = + typeof localProvider.loadSnapshot === 'function' + ? localProvider.loadSnapshot() + : null; + let isLocalAvailable = + localSnapshot == null + ? localProvider.isAvailable() + : localProvider.isAvailable(localSnapshot); + + if (requestedSource === 'local') { + if (!isLocalAvailable) { + let error = new Error( + 'No local workspace context found. Start a local TDD session or ensure .vizzly/ has report data.' + ); + error.code = 'LOCAL_WORKSPACE_CONTEXT'; + throw error; + } + + return 'local'; + } + + if ( + isLocalAvailable && + (localSnapshot == null + ? localProvider.canHandle(command, target) + : localProvider.canHandle(command, target, localSnapshot)) + ) { + return 'local'; + } + + return 'cloud'; +} diff --git a/src/reporter/src/api/client.js b/src/reporter/src/api/client.js index d24abce..0ccc983 100644 --- a/src/reporter/src/api/client.js +++ b/src/reporter/src/api/client.js @@ -9,7 +9,7 @@ * - api.auth.* - Authentication */ -import { normalizeReportData } from '../utils/report-data.js'; +import { normalizeReportData } from '../../../utils/report-data.js'; /** * Make a JSON API request diff --git a/src/reporter/src/providers/sse-provider.jsx b/src/reporter/src/providers/sse-provider.jsx index 1a5780b..ba716b4 100644 --- a/src/reporter/src/providers/sse-provider.jsx +++ b/src/reporter/src/providers/sse-provider.jsx @@ -1,10 +1,10 @@ import { useQueryClient } from '@tanstack/react-query'; import { createContext, useEffect, useRef, useState } from 'react'; -import { queryKeys } from '../lib/query-keys.js'; import { normalizeComparisonUpdate, normalizeReportData, -} from '../utils/report-data.js'; +} from '../../../utils/report-data.js'; +import { queryKeys } from '../lib/query-keys.js'; export let SSE_STATE = { CONNECTING: 'connecting', diff --git a/src/reporter/src/utils/report-data.js b/src/utils/report-data.js similarity index 100% rename from src/reporter/src/utils/report-data.js rename to src/utils/report-data.js diff --git a/tests/api/endpoints.test.js b/tests/api/endpoints.test.js index 540aedd..4270364 100644 --- a/tests/api/endpoints.test.js +++ b/tests/api/endpoints.test.js @@ -14,10 +14,15 @@ import { finalizeParallelBuild, getBatchHotspots, getBuild, + getBuildContext, getBuilds, getComparison, + getComparisonContext, getPreviewInfo, + getReviewQueueContext, + getScreenshotContext, getScreenshotHotspots, + getSimilarFingerprintContext, getTddBaselines, getTokenContext, searchComparisons, @@ -82,6 +87,78 @@ describe('api/endpoints', () => { }); }); + describe('context endpoints', () => { + it('requests build context endpoint', async () => { + let client = createMockClient({ resource: 'build_context' }); + + await getBuildContext(client, 'build-123'); + + assert.strictEqual( + client.getLastCall().endpoint, + '/api/sdk/context/builds/build-123' + ); + }); + + it('includes comparison context query params when provided', async () => { + let client = createMockClient({ resource: 'comparison_context' }); + + await getComparisonContext(client, 'comparison-123', { + similarLimit: 5, + recentLimit: 4, + windowSize: 12, + }); + + assert.strictEqual( + client.getLastCall().endpoint, + '/api/sdk/context/comparisons/comparison-123?similarLimit=5&recentLimit=4&windowSize=12' + ); + }); + + it('encodes screenshot names and applies scope query params', async () => { + let client = createMockClient({ resource: 'screenshot_context' }); + + await getScreenshotContext(client, 'Dashboard/Header', { + project: 'storybook', + organization: 'acme', + }); + + assert.strictEqual( + client.getLastCall().endpoint, + '/api/sdk/context/screenshots/Dashboard%2FHeader?project=storybook&organization=acme' + ); + }); + + it('builds fingerprint similarity endpoint with project scope', async () => { + let client = createMockClient({ resource: 'fingerprint_context' }); + + await getSimilarFingerprintContext(client, 'fp:dashboard', { + project: 'storybook', + limit: 7, + }); + + assert.strictEqual( + client.getLastCall().endpoint, + '/api/sdk/context/fingerprints/fp%3Adashboard/similar?project=storybook&limit=7' + ); + }); + + it('builds review queue endpoint with scope and pagination', async () => { + let client = createMockClient({ resource: 'review_queue_context' }); + + await getReviewQueueContext(client, { + project: 'storybook', + organization: 'acme', + limit: 15, + offset: 30, + }); + + assert.strictEqual( + client.getLastCall().endpoint, + '/api/sdk/context/review-queue?project=storybook&organization=acme&limit=15&offset=30' + ); + }); + }); + describe('getBuilds', () => { it('fetches builds with no filters', async () => { let client = createMockClient([{ id: '1' }, { id: '2' }]); diff --git a/tests/commands/context-cli.test.js b/tests/commands/context-cli.test.js new file mode 100644 index 0000000..f0f7559 --- /dev/null +++ b/tests/commands/context-cli.test.js @@ -0,0 +1,273 @@ +import assert from 'node:assert'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import { runCLI } from '../helpers/cli-runner.js'; + +function createWorkspaceFixture() { + let cwd = join( + tmpdir(), + `vizzly-context-local-${Date.now()}-${Math.random().toString(16).slice(2)}` + ); + let vizzlyDir = join(cwd, '.vizzly'); + let baselinesDir = join(vizzlyDir, 'baselines'); + + mkdirSync(baselinesDir, { recursive: true }); + + writeFileSync( + join(vizzlyDir, 'server.json'), + JSON.stringify({ + port: 4821, + pid: 999, + startTime: Date.now(), + buildId: 'local-build-1', + }) + ); + writeFileSync( + join(vizzlyDir, 'session.json'), + JSON.stringify({ + buildId: 'local-build-1', + branch: 'feature/local-context', + commit: 'abc1234', + createdAt: '2026-04-29T00:00:00.000Z', + }) + ); + writeFileSync( + join(vizzlyDir, 'report-data.json'), + JSON.stringify({ + timestamp: Date.now(), + summary: { + total: 2, + passed: 0, + failed: 1, + rejected: 0, + errors: 0, + }, + comparisons: [ + { + id: 'comp-settings', + name: 'Settings Panel', + originalName: 'Settings Panel', + signature: 'Settings Panel|1440|chrome', + status: 'failed', + current: '/images/current/settings-panel.png', + baseline: '/images/baselines/settings-panel.png', + diff: '/images/diffs/settings-panel.png', + properties: { + browser: 'chrome', + viewport_width: 1440, + viewport_height: 900, + }, + diffPercentage: 1.37, + diffCount: 245, + totalPixels: 921600, + }, + { + id: 'comp-dashboard', + name: 'Dashboard', + originalName: 'Dashboard', + signature: 'Dashboard|1440|chrome', + status: 'new', + current: '/images/current/dashboard.png', + baseline: null, + diff: null, + properties: { + browser: 'chrome', + viewport_width: 1440, + viewport_height: 900, + }, + diffPercentage: null, + diffCount: null, + totalPixels: 921600, + }, + ], + }) + ); + writeFileSync( + join(vizzlyDir, 'comparison-details.json'), + JSON.stringify({ + 'comp-settings': { + diffClusters: [{ x: 120, y: 96, width: 520, height: 164 }], + confirmedRegions: [ + { + id: 'region-1', + label: 'Known settings header band', + x1: 120, + y1: 96, + x2: 640, + y2: 260, + }, + ], + hotspotAnalysis: { confidence: 'high', confidenceScore: 92 }, + }, + }) + ); + writeFileSync( + join(vizzlyDir, 'hotspots.json'), + JSON.stringify({ + summary: { total_regions: 1 }, + hotspots: { + 'Settings Panel': { + regions: [{ y1: 96, y2: 260 }], + confidence: 'high', + }, + }, + }) + ); + writeFileSync( + join(vizzlyDir, 'regions.json'), + JSON.stringify({ + summary: { total_regions: 1 }, + regions: { + 'Settings Panel': { + confirmed: [ + { + id: 'region-1', + label: 'Known settings header band', + x1: 120, + y1: 96, + x2: 640, + y2: 260, + }, + ], + candidates: [], + }, + }, + }) + ); + + return cwd; +} + +describe('context CLI integration', () => { + it('treats root-level --json as a flag before the context command', async () => { + let result = await runCLI(['--json', 'context', 'build', 'build-123']); + + assert.notStrictEqual(result.code, 0); + assert.ok(!result.stderr.includes("unknown command 'build'")); + assert.ok(result.stderr.includes('API token required')); + }); + + it('reads local build context without requiring an API token', async () => { + let cwd = createWorkspaceFixture(); + let vizzlyHome = join(cwd, '.vizzly-home'); + mkdirSync(vizzlyHome, { recursive: true }); + + let result = await runCLI( + ['--json', 'context', 'build', 'current', '--source', 'local'], + { + cwd, + env: { + VIZZLY_HOME: vizzlyHome, + }, + } + ); + + assert.strictEqual(result.code, 0); + let parsed = JSON.parse(result.stdout); + assert.strictEqual(parsed.status, 'data'); + assert.strictEqual(parsed.data.source, 'local_workspace'); + assert.strictEqual(parsed.data.build.id, 'local-build-1'); + }); + + it('auto-selects local screenshot context when a workspace session is active', async () => { + let cwd = createWorkspaceFixture(); + let vizzlyHome = join(cwd, '.vizzly-home'); + mkdirSync(vizzlyHome, { recursive: true }); + + let result = await runCLI( + ['--json', 'context', 'screenshot', 'Settings Panel'], + { + cwd, + env: { + VIZZLY_HOME: vizzlyHome, + }, + } + ); + + assert.strictEqual(result.code, 0); + let parsed = JSON.parse(result.stdout); + assert.strictEqual(parsed.data.source, 'local_workspace'); + assert.strictEqual(parsed.data.screenshot.name, 'Settings Panel'); + assert.strictEqual( + parsed.data.confirmed_regions[0].label, + 'Known settings header band' + ); + }); + + it('reads local comparison context with diff memory details', async () => { + let cwd = createWorkspaceFixture(); + let vizzlyHome = join(cwd, '.vizzly-home'); + mkdirSync(vizzlyHome, { recursive: true }); + + let result = await runCLI( + ['--json', 'context', 'comparison', 'comp-settings', '--source', 'local'], + { + cwd, + env: { + VIZZLY_HOME: vizzlyHome, + }, + } + ); + + assert.strictEqual(result.code, 0); + let parsed = JSON.parse(result.stdout); + assert.strictEqual(parsed.data.source, 'local_workspace'); + assert.strictEqual(parsed.data.comparison.id, 'comp-settings'); + assert.strictEqual( + parsed.data.comparison.analysis.hotspot_analysis.confidence, + 'high' + ); + assert.strictEqual( + parsed.data.history.confirmed_regions[0].label, + 'Known settings header band' + ); + }); + + it('treats local review queue as unresolved local diffs', async () => { + let cwd = createWorkspaceFixture(); + let vizzlyHome = join(cwd, '.vizzly-home'); + mkdirSync(vizzlyHome, { recursive: true }); + + let result = await runCLI( + ['--json', 'context', 'review-queue', '--source', 'local'], + { + cwd, + env: { + VIZZLY_HOME: vizzlyHome, + }, + } + ); + + assert.strictEqual(result.code, 0); + let parsed = JSON.parse(result.stdout); + assert.strictEqual(parsed.data.source, 'local_workspace'); + assert.strictEqual(parsed.data.summary.total, 2); + assert.strictEqual(parsed.data.summary.changed, 1); + assert.strictEqual(parsed.data.summary.new, 1); + }); + + it('fails clearly when local fingerprint similarity is requested', async () => { + let cwd = createWorkspaceFixture(); + let vizzlyHome = join(cwd, '.vizzly-home'); + mkdirSync(vizzlyHome, { recursive: true }); + + let result = await runCLI( + ['context', 'similar', 'fp-settings', '--source', 'local'], + { + cwd, + env: { + VIZZLY_HOME: vizzlyHome, + }, + } + ); + + assert.notStrictEqual(result.code, 0); + assert.ok( + result.stderr.includes( + 'Local workspace context does not support fingerprint similarity yet' + ) + ); + }); +}); diff --git a/tests/commands/context.test.js b/tests/commands/context.test.js new file mode 100644 index 0000000..7c8da80 --- /dev/null +++ b/tests/commands/context.test.js @@ -0,0 +1,539 @@ +import assert from 'node:assert'; +import { describe, it } from 'node:test'; +import { + contextBuildCommand, + contextComparisonCommand, + contextReviewQueueCommand, + contextScreenshotCommand, + contextSimilarCommand, + validateContextBuildOptions, + validateContextComparisonOptions, + validateContextReviewQueueOptions, + validateContextScreenshotOptions, + validateContextSimilarOptions, +} from '../../src/commands/context.js'; + +function createMockOutput() { + let calls = []; + return { + calls, + configure: opts => calls.push({ method: 'configure', args: [opts] }), + error: (msg, err) => calls.push({ method: 'error', args: [msg, err] }), + startSpinner: msg => calls.push({ method: 'startSpinner', args: [msg] }), + stopSpinner: () => calls.push({ method: 'stopSpinner', args: [] }), + header: (cmd, mode) => calls.push({ method: 'header', args: [cmd, mode] }), + print: msg => calls.push({ method: 'print', args: [msg] }), + blank: () => calls.push({ method: 'blank', args: [] }), + hint: msg => calls.push({ method: 'hint', args: [msg] }), + labelValue: (label, value) => + calls.push({ method: 'labelValue', args: [label, value] }), + cleanup: () => calls.push({ method: 'cleanup', args: [] }), + data: obj => calls.push({ method: 'data', args: [obj] }), + getColors: () => ({ + bold: s => s, + dim: s => s, + brand: { + success: s => s, + warning: s => s, + error: s => s, + info: s => s, + }, + }), + }; +} + +describe('commands/context', () => { + describe('validation', () => { + it('rejects invalid source values', () => { + let errors = validateContextBuildOptions({ source: 'moon' }); + assert.ok(errors.includes('--source must be one of: auto, cloud, local')); + }); + + it('rejects out-of-range comparison context limits', () => { + let errors = validateContextComparisonOptions({ similarLimit: 51 }); + assert.ok( + errors.includes('--similar-limit must be a number between 1 and 50') + ); + }); + + it('requires project when org is provided for screenshot context', () => { + let errors = validateContextScreenshotOptions({ org: 'acme' }); + assert.ok(errors.includes('--org requires --project')); + }); + + it('rejects out-of-range similar limit', () => { + let errors = validateContextSimilarOptions({ limit: 0 }); + assert.ok(errors.includes('--limit must be a number between 1 and 50')); + }); + + it('rejects negative review queue offsets', () => { + let errors = validateContextReviewQueueOptions({ offset: -1 }); + assert.ok(errors.includes('--offset must be a non-negative number')); + }); + }); + + describe('contextBuildCommand', () => { + it('requires authentication', async () => { + let output = createMockOutput(); + let exitCode = null; + + await contextBuildCommand( + 'build-1', + {}, + {}, + { + loadConfig: async () => ({}), + output, + exit: code => { + exitCode = code; + }, + } + ); + + assert.strictEqual(exitCode, 1); + assert.ok(output.calls.some(call => call.method === 'error')); + }); + + it('returns build context in JSON mode', async () => { + let output = createMockOutput(); + let context = { + resource: 'build_context', + build: { id: 'build-1' }, + summary: { comparisons: { changed: 1 } }, + }; + + await contextBuildCommand( + 'build-1', + {}, + { json: true }, + { + loadConfig: async () => ({ + apiKey: 'token', + apiUrl: 'https://api.test', + }), + createApiClient: () => ({}), + getBuildContext: async () => context, + output, + exit: () => {}, + } + ); + + let dataCall = output.calls.find(call => call.method === 'data'); + assert.ok(dataCall); + assert.strictEqual(dataCall.args[0].resource, 'build_context'); + assert.strictEqual(dataCall.args[0].build.id, 'build-1'); + }); + + it('uses local workspace context without requiring authentication', async () => { + let output = createMockOutput(); + + await contextBuildCommand( + 'current', + { source: 'local' }, + { json: true }, + { + loadConfig: async () => ({ + apiUrl: 'https://api.test', + }), + resolveContextSource: () => 'local', + createLocalWorkspaceContextProvider: () => ({ + getBuildContext: async () => ({ + resource: 'build_context', + source: 'local_workspace', + build: { id: 'local-build' }, + }), + }), + output, + exit: () => {}, + } + ); + + let dataCall = output.calls.find(call => call.method === 'data'); + assert.ok(dataCall); + assert.strictEqual(dataCall.args[0].source, 'local_workspace'); + assert.strictEqual(dataCall.args[0].build.id, 'local-build'); + }); + + it('uses comparison results in human output instead of completed status', async () => { + let output = createMockOutput(); + + await contextBuildCommand( + 'build-1', + {}, + {}, + { + loadConfig: async () => ({ + apiKey: 'token', + apiUrl: 'https://api.test', + }), + createApiClient: () => ({}), + getBuildContext: async () => ({ + build: { + id: 'build-1', + name: 'Context Build', + status: 'completed', + }, + scope: { + organization: { slug: 'acme' }, + project: { slug: 'storybook' }, + }, + summary: { review: { pending: 1, approved: 0, rejected: 0 } }, + review: { comments: [], assignments: [] }, + comparisons: [ + { + screenshot: { name: 'Dashboard' }, + status: 'completed', + result: 'changed', + diff_percentage: 1.5, + analysis: { fingerprint_hash: 'fp-dashboard' }, + }, + { + screenshot: { name: 'Settings' }, + status: 'completed', + result: 'new', + diff_percentage: 4.25, + analysis: { fingerprint_hash: 'fp-settings' }, + }, + ], + links: {}, + }), + output, + exit: () => {}, + } + ); + + let printLines = output.calls + .filter(call => call.method === 'print') + .map(call => call.args[0]); + assert.ok(printLines.some(line => line.includes('Dashboard CHANGED'))); + assert.ok(printLines.some(line => line.includes('Settings NEW'))); + }); + }); + + describe('contextComparisonCommand', () => { + it('passes similarity and history limits through to the API helper', async () => { + let output = createMockOutput(); + let capturedQuery = null; + + await contextComparisonCommand( + 'comparison-1', + { similarLimit: 5, recentLimit: 4, windowSize: 12 }, + { json: true }, + { + loadConfig: async () => ({ + apiKey: 'token', + apiUrl: 'https://api.test', + }), + createApiClient: () => ({}), + getComparisonContext: async (_client, _id, query) => { + capturedQuery = query; + return { + resource: 'comparison_context', + comparison: { id: 'comparison-1' }, + }; + }, + output, + exit: () => {}, + } + ); + + assert.deepStrictEqual(capturedQuery, { + similarLimit: 5, + recentLimit: 4, + windowSize: 12, + }); + }); + + it('shows known region labels in human output', async () => { + let output = createMockOutput(); + + await contextComparisonCommand( + 'comparison-1', + {}, + {}, + { + loadConfig: async () => ({ + apiKey: 'token', + apiUrl: 'https://api.test', + }), + createApiClient: () => ({}), + getComparisonContext: async () => ({ + scope: { + organization: { slug: 'acme' }, + project: { slug: 'storybook' }, + }, + comparison: { + id: 'comparison-1', + result: 'changed', + status: 'completed', + screenshot: { name: 'Dashboard' }, + analysis: { + fingerprint_hash: 'fp-dashboard', + diff_image_url: 'https://cdn.test/diff.png', + }, + }, + history: { + similar_by_fingerprint: [], + recent_by_name: [], + confirmed_regions: [{ label: 'Known header copy band' }], + }, + review: { + build_comments: [], + screenshot_comments: [], + }, + links: {}, + }), + output, + exit: () => {}, + } + ); + + let knownRegionsCall = output.calls.find( + call => call.method === 'labelValue' && call.args[0] === 'Known Regions' + ); + assert.ok(knownRegionsCall); + assert.strictEqual(knownRegionsCall.args[1], 'Known header copy band'); + }); + }); + + describe('contextScreenshotCommand', () => { + it('passes project scope and history options to screenshot context', async () => { + let output = createMockOutput(); + let capturedQuery = null; + + await contextScreenshotCommand( + 'Dashboard', + { project: 'storybook', org: 'acme', recentLimit: 3, windowSize: 11 }, + { json: true }, + { + loadConfig: async () => ({ + apiKey: 'token', + apiUrl: 'https://api.test', + }), + createApiClient: () => ({}), + getScreenshotContext: async (_client, _name, query) => { + capturedQuery = query; + return { + resource: 'screenshot_context', + screenshot: { name: 'Dashboard' }, + }; + }, + output, + exit: () => {}, + } + ); + + assert.deepStrictEqual(capturedQuery, { + project: 'storybook', + organization: 'acme', + recentLimit: 3, + windowSize: 11, + }); + }); + + it('shows confirmed region labels in human output', async () => { + let output = createMockOutput(); + + await contextScreenshotCommand( + 'Dashboard', + { project: 'storybook', org: 'acme' }, + {}, + { + loadConfig: async () => ({ + apiKey: 'token', + apiUrl: 'https://api.test', + }), + createApiClient: () => ({}), + getScreenshotContext: async () => ({ + scope: { + organization: { slug: 'acme' }, + project: { slug: 'storybook' }, + }, + screenshot: { name: 'Dashboard' }, + hotspot_analysis: { + total_builds_analyzed: 2, + confidence: 'high', + }, + confirmed_regions: [{ label: 'Known header copy band' }], + history: { recent_comparisons: [] }, + }), + output, + exit: () => {}, + } + ); + + let knownRegionsCall = output.calls.find( + call => call.method === 'labelValue' && call.args[0] === 'Known Regions' + ); + assert.ok(knownRegionsCall); + assert.strictEqual(knownRegionsCall.args[1], 'Known header copy band'); + }); + }); + + describe('contextSimilarCommand', () => { + it('passes project-scoped fingerprint search options', async () => { + let output = createMockOutput(); + let capturedQuery = null; + + await contextSimilarCommand( + 'fp-dashboard', + { project: 'storybook', org: 'acme', limit: 7 }, + { json: true }, + { + loadConfig: async () => ({ + apiKey: 'token', + apiUrl: 'https://api.test', + }), + createApiClient: () => ({}), + getSimilarFingerprintContext: async (_client, _hash, query) => { + capturedQuery = query; + return { resource: 'fingerprint_context', comparisons: [] }; + }, + output, + exit: () => {}, + } + ); + + assert.deepStrictEqual(capturedQuery, { + project: 'storybook', + organization: 'acme', + limit: 7, + }); + }); + + it('fails clearly when local fingerprint similarity is unavailable', async () => { + let output = createMockOutput(); + let exitCode = null; + + await contextSimilarCommand( + 'fp-dashboard', + { source: 'local' }, + {}, + { + loadConfig: async () => ({ + apiUrl: 'https://api.test', + }), + resolveContextSource: () => 'local', + createLocalWorkspaceContextProvider: () => ({ + getSimilarFingerprintContext: async () => { + throw new Error( + 'Local workspace context does not support fingerprint similarity yet. Use --source cloud for this query.' + ); + }, + }), + output, + exit: code => { + exitCode = code; + }, + } + ); + + assert.strictEqual(exitCode, 1); + assert.ok( + output.calls.some( + call => + call.method === 'error' && + call.args[0] === 'Failed to fetch similar visual context' + ) + ); + }); + + it('explains the local similarity gap before an auth error in auto mode', async () => { + let output = createMockOutput(); + let exitCode = null; + + await contextSimilarCommand( + 'fp-dashboard', + {}, + {}, + { + loadConfig: async () => ({ + apiUrl: 'https://api.test', + }), + createLocalWorkspaceContextProvider: () => ({ + isAvailable: () => true, + canHandle: () => false, + }), + output, + exit: code => { + exitCode = code; + }, + } + ); + + assert.strictEqual(exitCode, 1); + let errorCall = output.calls.find(call => call.method === 'error'); + assert.ok(errorCall); + assert.strictEqual( + errorCall.args[1]?.message, + 'Local workspace context does not support fingerprint similarity yet. Use --source cloud for this query.' + ); + }); + + it('renders nested fingerprint hashes in human output', async () => { + let output = createMockOutput(); + + await contextSimilarCommand( + 'fp-dashboard', + { project: 'storybook', org: 'acme' }, + {}, + { + loadConfig: async () => ({ + apiKey: 'token', + apiUrl: 'https://api.test', + }), + createApiClient: () => ({}), + getSimilarFingerprintContext: async () => ({ + scope: { + organization: { slug: 'acme' }, + project: { slug: 'storybook' }, + }, + fingerprint: { hash: 'fp-dashboard' }, + matches: [], + }), + output, + exit: () => {}, + } + ); + + assert.ok( + output.calls.some( + call => + call.method === 'print' && call.args[0].includes('fp-dashboard') + ) + ); + }); + }); + + describe('contextReviewQueueCommand', () => { + it('passes review queue scope and pagination to the API helper', async () => { + let output = createMockOutput(); + let capturedQuery = null; + + await contextReviewQueueCommand( + { project: 'storybook', org: 'acme', limit: 15, offset: 30 }, + { json: true }, + { + loadConfig: async () => ({ + apiKey: 'token', + apiUrl: 'https://api.test', + }), + createApiClient: () => ({}), + getReviewQueueContext: async (_client, query) => { + capturedQuery = query; + return { resource: 'review_queue_context', comparisons: [] }; + }, + output, + exit: () => {}, + } + ); + + assert.deepStrictEqual(capturedQuery, { + project: 'storybook', + organization: 'acme', + limit: 15, + offset: 30, + }); + }); + }); +}); diff --git a/tests/context/local-workspace-provider.test.js b/tests/context/local-workspace-provider.test.js new file mode 100644 index 0000000..2278ab4 --- /dev/null +++ b/tests/context/local-workspace-provider.test.js @@ -0,0 +1,148 @@ +import assert from 'node:assert'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import { createLocalWorkspaceContextProvider } from '../../src/context/local-workspace-provider.js'; + +function createWorkspacePaths(projectRoot) { + let vizzlyDir = join(projectRoot, '.vizzly'); + + return { + server: join(vizzlyDir, 'server.json'), + session: join(vizzlyDir, 'session.json'), + report: join(vizzlyDir, 'report-data.json'), + comparisonDetails: join(vizzlyDir, 'comparison-details.json'), + baselineMetadata: join(vizzlyDir, 'baselines', 'metadata.json'), + hotspots: join(vizzlyDir, 'hotspots.json'), + regions: join(vizzlyDir, 'regions.json'), + }; +} + +describe('context/local-workspace-provider', () => { + it('preserves absolute asset paths in local comparison context', () => { + let projectRoot = '/tmp/vizzly-local-workspace'; + let paths = createWorkspacePaths(projectRoot); + let absoluteCurrent = join(projectRoot, 'artifacts', 'current.png'); + + let provider = createLocalWorkspaceContextProvider( + { projectRoot }, + { + readJsonIfExists: path => { + if (path === paths.report) { + return { + comparisons: [ + { + id: 'comp-1', + name: 'Dashboard', + originalName: 'Dashboard', + status: 'failed', + current: absoluteCurrent, + baseline: join(projectRoot, 'artifacts', 'baseline.png'), + diff: join(projectRoot, 'artifacts', 'diff.png'), + properties: {}, + }, + ], + }; + } + + if (path === paths.comparisonDetails) { + return {}; + } + + return null; + }, + } + ); + + let context = provider.getComparisonContext('comp-1'); + + assert.strictEqual( + context.comparison.screenshot.original_url, + absoluteCurrent + ); + }); + + it('reuses one snapshot across availability and lookup calls', () => { + let projectRoot = '/tmp/vizzly-local-cache'; + let paths = createWorkspacePaths(projectRoot); + let readCount = 0; + + let provider = createLocalWorkspaceContextProvider( + { projectRoot }, + { + readJsonIfExists: path => { + readCount += 1; + + if (path === paths.server) { + return { port: 47392, buildId: 'local-build' }; + } + + if (path === paths.report) { + return { + comparisons: [ + { + id: 'comp-1', + name: 'Dashboard', + originalName: 'Dashboard', + status: 'failed', + current: '/images/current/dashboard.png', + baseline: null, + diff: null, + properties: {}, + }, + ], + }; + } + + if (path === paths.comparisonDetails) { + return {}; + } + + return null; + }, + } + ); + + assert.strictEqual(provider.isAvailable(), true); + assert.strictEqual(provider.canHandle('comparison', 'comp-1'), true); + provider.getBuildContext('local-build'); + + assert.strictEqual(readCount, 7); + }); + + it('caps the default local review queue size', () => { + let projectRoot = '/tmp/vizzly-local-review-queue'; + let paths = createWorkspacePaths(projectRoot); + let comparisons = Array.from({ length: 80 }, (_, index) => ({ + id: `comp-${index}`, + name: `Screenshot ${index}`, + originalName: `Screenshot ${index}`, + status: 'failed', + current: `/images/current/${index}.png`, + baseline: null, + diff: null, + properties: {}, + })); + + let provider = createLocalWorkspaceContextProvider( + { projectRoot }, + { + readJsonIfExists: path => { + if (path === paths.report) { + return { comparisons }; + } + + if (path === paths.comparisonDetails) { + return {}; + } + + return null; + }, + } + ); + + let context = provider.getReviewQueueContext(); + + assert.strictEqual(context.summary.total, 80); + assert.strictEqual(context.comparisons.length, 50); + }); +}); diff --git a/tests/context/provider-resolver.test.js b/tests/context/provider-resolver.test.js new file mode 100644 index 0000000..d3a5b50 --- /dev/null +++ b/tests/context/provider-resolver.test.js @@ -0,0 +1,67 @@ +import assert from 'node:assert'; +import { describe, it } from 'node:test'; +import { resolveContextSource } from '../../src/context/provider-resolver.js'; + +describe('context/provider-resolver', () => { + it('returns cloud when the source is explicitly cloud', () => { + let source = resolveContextSource( + { requestedSource: 'cloud', command: 'build', target: 'build-1' }, + { + createLocalWorkspaceContextProvider: () => { + throw new Error('local provider should not be created'); + }, + } + ); + + assert.strictEqual(source, 'cloud'); + }); + + it('throws when local source is requested but no workspace context exists', () => { + assert.throws( + () => + resolveContextSource( + { requestedSource: 'local', command: 'build', target: 'current' }, + { + createLocalWorkspaceContextProvider: () => ({ + isAvailable: () => false, + canHandle: () => false, + }), + } + ), + error => { + assert.strictEqual(error.code, 'LOCAL_WORKSPACE_CONTEXT'); + assert.match(error.message, /No local workspace context found/); + return true; + } + ); + }); + + it('prefers local context in auto mode when the workspace can answer the query', () => { + let source = resolveContextSource( + { requestedSource: 'auto', command: 'screenshot', target: 'Dashboard' }, + { + createLocalWorkspaceContextProvider: () => ({ + isAvailable: () => true, + canHandle: (command, target) => + command === 'screenshot' && target === 'Dashboard', + }), + } + ); + + assert.strictEqual(source, 'local'); + }); + + it('falls back to cloud in auto mode when the workspace cannot answer the query', () => { + let source = resolveContextSource( + { requestedSource: 'auto', command: 'similar', target: 'fp-dashboard' }, + { + createLocalWorkspaceContextProvider: () => ({ + isAvailable: () => true, + canHandle: () => false, + }), + } + ); + + assert.strictEqual(source, 'cloud'); + }); +}); diff --git a/tests/reporter/utils/report-data.test.js b/tests/reporter/utils/report-data.test.js index f786bc9..2273790 100644 --- a/tests/reporter/utils/report-data.test.js +++ b/tests/reporter/utils/report-data.test.js @@ -3,7 +3,7 @@ import { describe, it } from 'node:test'; import { normalizeComparisonUpdate, normalizeReportData, -} from '../../../src/reporter/src/utils/report-data.js'; +} from '../../../src/utils/report-data.js'; describe('reporter/utils/report-data', () => { describe('normalizeReportData', () => {