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', () => {