diff --git a/README.md b/README.md index 2c79033..007d8f6 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,23 @@ # Vizzly CLI -> Visual proof that your UI works +> Reviewed UI context for people and LLM agents [![npm version](https://img.shields.io/npm/v/@vizzly-testing/cli.svg)](https://www.npmjs.com/package/@vizzly-testing/cli) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -Visual bugs slip through code review. They hide in pixel-perfect mockups, sneak past unit tests, and show up right when you're about to ship. Vizzly catches them first. +Vizzly keeps the visual truth behind your UI: approved baselines, meaningful diffs, review state, comments, and preview links in one place. That makes it useful for humans reviewing product changes and for LLM agents that need to understand what the UI is supposed to look like before they edit it. -Unlike tools that re-render components in isolation, Vizzly captures screenshots directly from your functional tests—the *real thing*. Whether you're validating AI-generated code or testing manual changes, you get visual proof before anything hits production. +Unlike tools that re-render components in isolation, Vizzly captures screenshots directly from your functional tests: the real thing. Whether you're validating AI-generated code or testing manual changes, you get reviewed UI context before anything hits production. ## Why Vizzly? -**Local TDD workflow.** See changes as you type, not after CI. The `vizzly tdd` command runs a local dashboard that compares screenshots instantly—no cloud roundtrip, no waiting. +**Local TDD workflow.** See changes as you type, not after CI. The `vizzly tdd` command runs a local dashboard that compares screenshots instantly and exposes the current workspace as local context. -**Smart diffing with Honeydiff.** Our Rust-based comparison engine is 12x faster than alternatives and ignores the noise: timestamps, ads, font rendering differences. It finds real changes. +**Meaningful diff metadata.** Vizzly stores rich diff evidence: changed regions, cluster metadata, fingerprints, hotspots, confirmed regions, and image URLs. Agents can inspect what changed instead of guessing from a pass/fail label. **Any screenshot source.** Playwright, Cypress, Puppeteer, Selenium, native mobile apps, or even design mockups. If you can capture it, Vizzly can compare it. -**Team-based pricing.** Pay for your team, not your screenshots. Test everything without budget anxiety. +**Approved baselines as truth.** Cloud context carries human review state. Local context carries the downloaded or generated baseline metadata. That is the bridge between TDD locally and collaborative review in Vizzly. ## Quick Start @@ -60,7 +60,7 @@ 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. +Use `vizzly context` when you want Vizzly to act like a visual context store, not just 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 @@ -73,11 +73,11 @@ vizzly context comparison def456 --json # Local workspace context from .vizzly/ vizzly context build current --source local +vizzly context build current --source local --agent 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. +`--json` is the durable automation path. `--agent` gives a compact Markdown handoff for prompt assembly and local dogfooding. 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. diff --git a/clients/ember/test-app/package-lock.json b/clients/ember/test-app/package-lock.json index b5f972c..11d5645 100644 --- a/clients/ember/test-app/package-lock.json +++ b/clients/ember/test-app/package-lock.json @@ -68,13 +68,13 @@ }, "..": { "name": "@vizzly-testing/ember", - "version": "0.1.0", + "version": "0.0.3", "license": "MIT", "dependencies": { "playwright-core": "^1.49.0" }, "bin": { - "vizzly-browser": "bin/vizzly-browser.js" + "vizzly-testem-launcher": "bin/vizzly-testem-launcher.js" }, "devDependencies": { "@biomejs/biome": "^2.3.8" diff --git a/clients/storybook/src/index.js b/clients/storybook/src/index.js index 4a1fe1c..38523ff 100644 --- a/clients/storybook/src/index.js +++ b/clients/storybook/src/index.js @@ -128,7 +128,7 @@ export async function run(storybookPath, options = {}, context = {}) { branch = gitInfo.branch; commit = gitInfo.commit; message = gitInfo.message; - buildName = gitInfo.buildName; + buildName = vizzlyConfig?.build?.name || gitInfo.buildName; pullRequestNumber = gitInfo.prNumber; } else { // Fallback for older CLI versions - use environment variables @@ -138,7 +138,9 @@ export async function run(storybookPath, options = {}, context = {}) { branch = process.env.VIZZLY_BRANCH || 'main'; commit = process.env.VIZZLY_COMMIT_SHA || undefined; message = process.env.VIZZLY_COMMIT_MESSAGE || undefined; - buildName = `Storybook ${new Date().toISOString()}`; + buildName = + vizzlyConfig?.build?.name || + `Storybook ${new Date().toISOString()}`; pullRequestNumber = process.env.VIZZLY_PR_NUMBER ? parseInt(process.env.VIZZLY_PR_NUMBER, 10) : undefined; diff --git a/clients/swift/scripts/run-e2e.js b/clients/swift/scripts/run-e2e.js index 00811cc..25752a9 100644 --- a/clients/swift/scripts/run-e2e.js +++ b/clients/swift/scripts/run-e2e.js @@ -1,4 +1,4 @@ -import { spawn } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import { existsSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; @@ -16,6 +16,7 @@ if (!existsSync(distCliPath)) { } let tempDir = mkdtempSync(join(tmpdir(), 'vizzly-swift-e2e-')); +let vizzlyHome = join(tempDir, '.vizzly-home'); let swiftTestCommand = [ 'cd', JSON.stringify(swiftPackageDir), @@ -26,6 +27,36 @@ let swiftTestCommand = [ 'VizzlyE2ETests', ].join(' '); +function cleanupAndExit(code = 1) { + rmSync(tempDir, { recursive: true, force: true }); + process.exit(code); +} + +function printLocalContext() { + let contextResult = spawnSync( + process.execPath, + [cliPath, 'context', 'build', 'current', '--source', 'local', '--agent'], + { + cwd: tempDir, + encoding: 'utf8', + env: { + ...process.env, + VIZZLY_HOME: vizzlyHome, + }, + } + ); + + if (contextResult.stdout) { + process.stdout.write(contextResult.stdout); + } + + if (contextResult.stderr) { + process.stderr.write(contextResult.stderr); + } + + return contextResult.status ?? 1; +} + let child = spawn( process.execPath, [cliPath, 'tdd', 'run', swiftTestCommand, '--no-color'], @@ -35,18 +66,21 @@ let child = spawn( env: { ...process.env, VIZZLY_E2E: '1', - VIZZLY_HOME: join(tempDir, '.vizzly-home'), + VIZZLY_HOME: vizzlyHome, }, } ); child.on('exit', code => { - rmSync(tempDir, { recursive: true, force: true }); - process.exit(code ?? 1); + if (code !== 0) { + cleanupAndExit(code ?? 1); + } + + let contextStatus = printLocalContext(); + cleanupAndExit(contextStatus); }); child.on('error', error => { - rmSync(tempDir, { recursive: true, force: true }); console.error(error); - process.exit(1); + cleanupAndExit(1); }); diff --git a/docs/json-output.md b/docs/json-output.md index 3ea95f9..097ce40 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -193,8 +193,8 @@ 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. +This is the best fit for automation, agents, and scripts that need approved baselines, visual +evidence, review state, comments, preview links, and diff metadata in one place. Every context payload includes a `source` field. That tells you whether the bundle came from cloud data or your local `.vizzly` workspace. @@ -204,8 +204,12 @@ cloud data or your local `.vizzly` workspace. ```bash vizzly context build abc123 --json vizzly context build current --source local --json +vizzly context build current --source local --agent ``` +Use `--json` for durable automation. Use `--agent` when you want a compact Markdown handoff for +prompt assembly. + ```json { "resource": "build_context", @@ -219,6 +223,21 @@ vizzly context build current --source local --json "status": "completed", "approval_status": "pending" }, + "baseline": { + "selected": { + "id": "baseline-build", + "name": "Approved Main", + "approval_status": "approved" + }, + "selection_reason": "common_ancestor", + "comparison_baseline_build_ids": ["baseline-build"] + }, + "status": { + "needs_review": true, + "reasons": ["comparisons_need_review"], + "pending_comparisons": 3, + "unresolved_comments": 0 + }, "summary": { "comparisons": { "total": 12, @@ -231,14 +250,30 @@ vizzly context build current --source local --json "rejected": 0 } }, + "screenshots": [ + { + "name": "Dashboard", + "url": "https://...", + "baseline": { "url": "https://..." } + } + ], "comparisons": [ { "id": "cmp-1", - "name": "Dashboard", + "screenshot_name": "Dashboard", "result": "changed", - "diff_percentage": 0.42 + "needs_review": true, + "diff": { + "percentage": 0.42, + "image_url": "https://...", + "regions": [] + } } - ] + ], + "comments": { + "build": [], + "screenshot_count": 0 + } } ``` @@ -637,27 +672,19 @@ vizzly init --json } ``` -### `vizzly project:select` +### `vizzly project link` ```bash -vizzly project:select --json +vizzly project link my-org/my-project --json ``` -Note: In JSON mode, the interactive prompts still appear because project selection requires user input. - ```json { - "status": "configured", - "project": { - "name": "My Project", - "slug": "my-project" - }, - "organization": { - "name": "My Org", - "slug": "my-org" - }, - "directory": "/path/to/project", - "tokenCreated": true + "linked": true, + "organizationSlug": "my-org", + "projectSlug": "my-project", + "tokenPrefix": "vzt_abc", + "storage": "keychain" } ``` diff --git a/package.json b/package.json index 2cacf5b..bafd224 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,14 @@ { "name": "@vizzly-testing/cli", "version": "0.30.0", - "description": "Visual review platform for UI developers and designers", + "description": "Reviewed UI context for people and LLM agents", "keywords": [ + "llm-context", + "ui-context", + "visual-context", + "approved-baselines", "visual-testing", "screenshot-testing", - "visual-regression", "visual-review", "ui-testing", "collaboration", diff --git a/src/api/client.js b/src/api/client.js index c4a663f..ee3dfd6 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -23,6 +23,10 @@ import { */ export const DEFAULT_API_URL = 'https://app.vizzly.dev'; +function isProjectToken(token) { + return typeof token === 'string' && token.startsWith('vzt_'); +} + /** * Create an API client with the given configuration * @@ -84,7 +88,7 @@ export function createApiClient(options = {}) { shouldRetryWithRefresh( response.status, isRetry, - await hasRefreshToken() + !isProjectToken(token) && (await hasRefreshToken()) ) ) { let refreshed = await attemptTokenRefresh(); @@ -97,7 +101,7 @@ export function createApiClient(options = {}) { // Auth error if (isAuthError(response.status)) { throw new AuthError( - 'Invalid or expired API token. Link a project via "vizzly project:select" or set VIZZLY_TOKEN.' + 'Invalid or expired API token. Run "vizzly project link /" or set VIZZLY_TOKEN.' ); } @@ -146,7 +150,9 @@ export function createApiClient(options = {}) { await saveAuthTokens({ accessToken: data.accessToken, refreshToken: data.refreshToken, - expiresAt: data.expiresAt, + expiresAt: + data.expiresAt || + new Date(Date.now() + data.expiresIn * 1000).toISOString(), user: auth.user, }); diff --git a/src/cli.js b/src/cli.js index d487a6c..5a84e9c 100644 --- a/src/cli.js +++ b/src/cli.js @@ -34,6 +34,10 @@ import { loginCommand, validateLoginOptions } from './commands/login.js'; import { logoutCommand, validateLogoutOptions } from './commands/logout.js'; import { orgsCommand, validateOrgsOptions } from './commands/orgs.js'; import { previewCommand, validatePreviewOptions } from './commands/preview.js'; +import { + projectLinkCommand, + validateProjectLinkOptions, +} from './commands/project.js'; import { projectsCommand, validateProjectsOptions, @@ -88,7 +92,7 @@ const formatHelp = (cmd, helper) => { // Cute grizzly bear mascot with square eyes (like the Vizzly logo!) lines.push(c.brand.amber(' ʕ□ᴥ□ʔ')); lines.push(` ${c.brand.amber(c.bold('vizzly'))} ${c.dim(`v${version}`)}`); - lines.push(` ${c.gray('Visual regression testing for UI teams')}`); + lines.push(` ${c.gray('Reviewed UI context for people and agents')}`); } else { // Compact header for subcommands lines.push(` ${c.brand.amber(c.bold('vizzly'))} ${c.white(cmd.name())}`); @@ -229,7 +233,7 @@ const formatHelp = (cmd, helper) => { if (isRootCommand) { lines.push(` ${c.brand.amber('▸')} ${c.bold('Quick Start')}`); lines.push(''); - lines.push(` ${c.dim('# Local visual testing')}`); + lines.push(` ${c.dim('# Local UI context')}`); lines.push(` ${c.gray('$')} ${c.white('vizzly tdd start')}`); lines.push(''); lines.push(` ${c.dim('# CI pipeline')}`); @@ -328,7 +332,7 @@ function normalizeJsonArgv(argv, commandNames) { program .name('vizzly') - .description('Vizzly CLI for visual regression testing') + .description('Vizzly CLI for reviewed UI context') .version(getPackageVersion()) .option('-c, --config ', 'Config file path') .option('--token ', 'Vizzly API token') @@ -444,10 +448,10 @@ program await uploadCommand(path, options, globalOptions); }); -// TDD command with subcommands - Local visual testing with interactive dashboard +// TDD command with subcommands - local reviewed UI context with an interactive dashboard const tddCmd = program .command('tdd') - .description('Run tests in TDD mode with local visual comparisons'); + .description('Run tests in TDD mode with local UI context'); // TDD Start - Background server tddCmd @@ -519,7 +523,7 @@ tddCmd // TDD Run - One-off test run with ephemeral server (generates static report) tddCmd .command('run ') - .description('Run tests once in TDD mode with local visual comparisons') + .description('Run tests once in TDD mode with local UI context') .option('--port ', 'Port for TDD server', '47392') .option('--branch ', 'Git branch override') .option('--environment ', 'Environment name', 'test') @@ -764,19 +768,21 @@ Examples: let contextCmd = program .command('context') - .description('Fetch visual context bundles for agents and reviewers'); + .description('Fetch reviewed UI context for agents and reviewers'); contextCmd .command('build') - .description('Fetch a build context bundle') + .description('Fetch reviewed UI context for a build') .argument('', 'Build ID to fetch context for') .option('--source ', 'Context source: auto, cloud, or local', 'auto') + .option('--agent', 'Output compact Markdown context for LLM agents') .addHelpText( 'after', ` Examples: $ vizzly context build abc123 $ vizzly context build current --source local + $ vizzly context build current --source local --agent $ vizzly context build abc123 --json $ vizzly context build abc123 --json build.id,summary.comparisons ` @@ -1220,6 +1226,43 @@ Workflow: await projectsCommand(options, globalOptions); }); +let projectCommand = program + .command('project') + .description('Manage the project linked to this local checkout'); + +projectCommand + .command('link [selector]') + .description('Link a Vizzly project for cloud uploads') + .option('--org ', 'Organization slug') + .option('--project ', 'Project slug') + .option('--name ', 'Credential name shown in Vizzly') + .option('--expires-at ', 'Optional token expiration timestamp') + .addHelpText( + 'after', + ` +Examples: + $ vizzly project link vizzly/storybook + $ vizzly project link --org vizzly --project storybook + +Note: run "vizzly login" first. The linked credential is project-scoped and is +used for cloud uploads; your user login remains separate for review actions. +` + ) + .action(async (selector, options) => { + const globalOptions = program.opts(); + + const validationErrors = validateProjectLinkOptions(selector, options); + if (validationErrors.length > 0) { + output.error('Validation errors:'); + for (let error of validationErrors) { + output.printErr(` - ${error}`); + } + process.exit(1); + } + + await projectLinkCommand(selector, options, globalOptions); + }); + program .command('finalize') .description('Finalize a parallel build after all shards complete') diff --git a/src/commands/builds.js b/src/commands/builds.js index 58b6085..85332b1 100644 --- a/src/commands/builds.js +++ b/src/commands/builds.js @@ -41,10 +41,12 @@ export async function buildsCommand( let allOptions = { ...globalOptions, ...options }; let config = await loadConfig(globalOptions.config, allOptions); - // Validate API token - if (!config.apiKey) { + let token = config.apiKey || config.userToken; + + // Validate cloud auth + if (!token) { output.error( - 'API token required. Use --token or set VIZZLY_TOKEN environment variable' + 'Authentication required. Use --token, set VIZZLY_TOKEN, or run "vizzly login"' ); exit(1); return; @@ -52,7 +54,7 @@ export async function buildsCommand( let client = createApiClient({ baseUrl: config.apiUrl, - token: config.apiKey, + token, command: 'builds', }); diff --git a/src/commands/context.js b/src/commands/context.js index eb62627..128d735 100644 --- a/src/commands/context.js +++ b/src/commands/context.js @@ -16,7 +16,7 @@ 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"'; + return 'Authentication required. Use --token, set VIZZLY_TOKEN, run "vizzly login", or link a project.'; } function buildSourceErrorMessage() { @@ -72,7 +72,7 @@ function validateScopedProjectOptions(options = {}) { function createClient(config, createApiClient) { return createApiClient({ baseUrl: config.apiUrl, - token: config.apiKey, + token: config.apiKey || config.userToken, command: 'context', }); } @@ -88,7 +88,7 @@ async function loadContextConfig(globalOptions, options, deps) { let allOptions = { ...globalOptions, ...options }; let config = await loadConfig(globalOptions.config, allOptions); - if (requireApiKey && !config.apiKey) { + if (requireApiKey && !config.apiKey && !config.userToken) { output.error(buildAuthErrorMessage()); output.cleanup(); exit(1); @@ -184,7 +184,7 @@ async function loadContextRuntime( } ); - if (source === 'cloud' && !config.apiKey) { + if (source === 'cloud' && !config.apiKey && !config.userToken) { if ( shouldExplainLocalSimilarityGap(requestedSource, command, localProvider) ) { @@ -247,6 +247,81 @@ function getComparisonDisplayState(comparison = {}) { return comparison.result || comparison.status || 'unknown'; } +function isChangedComparison(comparison = {}) { + return ['changed', 'failed', 'pending'].includes( + getComparisonDisplayState(comparison) + ); +} + +function isNewComparison(comparison = {}) { + return getComparisonDisplayState(comparison) === 'new'; +} + +function getComparisonName(comparison = {}) { + return ( + comparison.screenshot_name || + comparison.screenshot?.name || + comparison.name || + comparison.id || + 'unknown screenshot' + ); +} + +function getComparisonDiffPercentage(comparison = {}) { + return comparison.diff?.percentage ?? comparison.diff_percentage ?? null; +} + +function getComparisonFingerprint(comparison = {}) { + return ( + comparison.diff?.fingerprint_hash || + comparison.analysis?.fingerprint_hash || + null + ); +} + +function getBuildCommentsCount(context = {}) { + if (Array.isArray(context.comments?.build)) { + return context.comments.build.length; + } + + if (Array.isArray(context.review?.comments)) { + return context.review.comments.length; + } + + return 0; +} + +function getScreenshotCommentsCount(context = {}) { + if (Number.isInteger(context.comments?.screenshot_count)) { + return context.comments.screenshot_count; + } + + return 0; +} + +function getReviewAssignmentsCount(context = {}) { + if (Array.isArray(context.review?.assignments)) { + return context.review.assignments.length; + } + + return 0; +} + +function formatNeedsReview(status = {}) { + if (!status || status.needs_review == null) { + return null; + } + + let pending = status.pending_comparisons || 0; + let unresolved = status.unresolved_comments || 0; + + if (!status.needs_review) { + return 'no'; + } + + return `yes · ${pending} comparisons · ${unresolved} unresolved comments`; +} + function formatConfirmedRegionLabels(regions = []) { return regions .map(region => region.label) @@ -261,19 +336,21 @@ function printComparisonList(output, comparisons = [], { limit = 5 } = {}) { 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 screenshotName = getComparisonName(comparison); + let rawDiffPercentage = getComparisonDiffPercentage(comparison); let diffPercentage = - comparison.diff_percentage == null - ? null - : `${comparison.diff_percentage}%`; - let fingerprint = comparison.analysis?.fingerprint_hash || null; + rawDiffPercentage == null ? null : `${rawDiffPercentage}%`; + let fingerprint = getComparisonFingerprint(comparison); let details = []; if (diffPercentage) { details.push(diffPercentage); } + if (comparison.needs_review) { + details.push('needs review'); + } + if (fingerprint) { details.push(`fp:${fingerprint}`); } @@ -296,6 +373,12 @@ function displayBuildContext(output, context) { let colors = output.getColors(); let buildTone = getStatusTone(colors, context.build.status); + let comparisons = context.comparisons || []; + let screenshots = context.screenshots || []; + let reviewSummary = context.summary?.review || {}; + let commentsSummary = context.summary?.comments || {}; + let needsReview = formatNeedsReview(context.status); + let baseline = context.baseline?.selected || null; output.print( ` ${colors.bold(context.build.name || context.build.id)} ${buildTone((context.build.status || 'unknown').toUpperCase())}` @@ -305,20 +388,33 @@ function displayBuildContext(output, context) { ); output.blank(); - output.labelValue('Comparisons', String(context.comparisons.length)); + output.labelValue('Comparisons', String(comparisons.length)); + if (screenshots.length > 0) { + output.labelValue('Screenshots', String(screenshots.length)); + } + if (baseline) { + output.labelValue( + 'Baseline', + `${baseline.name || baseline.id || 'selected'}${context.baseline.selection_reason ? ` · ${context.baseline.selection_reason}` : ''}` + ); + } + if (needsReview) { + output.labelValue('Needs Review', needsReview); + } output.labelValue( 'Review', - `${context.summary.review.pending || 0} pending · ${context.summary.review.approved || 0} approved · ${context.summary.review.rejected || 0} rejected` + `${reviewSummary.pending || 0} pending · ${reviewSummary.approved || 0} approved · ${reviewSummary.rejected || 0} rejected` ); output.labelValue( 'Memory', - `${context.review.comments.length} build comments · ${context.review.assignments.length} assignments` + `${commentsSummary.build ?? getBuildCommentsCount(context)} build comments · ${commentsSummary.screenshot ?? getScreenshotCommentsCount(context)} screenshot comments · ${getReviewAssignmentsCount(context)} assignments` ); if (context.preview) { + let previewUrl = context.preview.preview_url || context.preview.url; output.labelValue( 'Preview', - `${context.preview.status}${context.preview.preview_url ? ' · available' : ''}` + `${context.preview.status || 'unknown'}${previewUrl ? ' · available' : ''}` ); } @@ -326,13 +422,100 @@ function displayBuildContext(output, context) { output.labelValue('Build URL', context.links.build_url); } - if (context.comparisons.length > 0) { + if (comparisons.length > 0) { output.blank(); output.print(' Comparisons'); - printComparisonList(output, context.comparisons); + printComparisonList(output, comparisons); } } +function formatAgentBuildContext(context) { + let comparisons = context.comparisons || []; + let changed = comparisons.filter(isChangedComparison); + let fresh = comparisons.filter(isNewComparison); + let needsReview = comparisons.filter(comparison => comparison.needs_review); + let baseline = context.baseline?.selected; + let lines = [ + `# Vizzly Visual Context: ${context.build?.name || context.build?.id || 'Build'}`, + '', + `Project: ${context.scope?.organization?.slug || 'unknown'}/${context.scope?.project?.slug || 'unknown'}`, + `Build: ${context.build?.id || 'unknown'} (${context.build?.status || 'unknown'})`, + ]; + + if (baseline) { + lines.push( + `Approved baseline: ${baseline.name || baseline.id || 'selected'} (${baseline.approval_status || 'unknown'})` + ); + } + + if (context.status) { + lines.push( + `Needs review: ${context.status.needs_review ? 'yes' : 'no'} (${context.status.pending_comparisons || 0} pending comparisons)` + ); + } + + if (context.preview?.url || context.preview?.preview_url) { + lines.push( + `Preview: ${context.preview.url || context.preview.preview_url}` + ); + } + + if (context.links?.build_url) { + lines.push(`Build URL: ${context.links.build_url}`); + } + + if (context.links?.report_url) { + lines.push(`Report: ${context.links.report_url}`); + } + + lines.push(''); + lines.push('## Diff Summary'); + lines.push(`- Total comparisons: ${comparisons.length}`); + lines.push(`- Changed: ${changed.length}`); + lines.push(`- New: ${fresh.length}`); + lines.push(`- Needs review: ${needsReview.length}`); + + if (changed.length > 0 || fresh.length > 0) { + lines.push(''); + lines.push('## Evidence To Inspect'); + + for (let comparison of [...changed, ...fresh].slice(0, 10)) { + let diffPercentage = getComparisonDiffPercentage(comparison); + let detail = diffPercentage == null ? '' : ` · ${diffPercentage}% diff`; + let diffUrl = + comparison.diff?.image_url || comparison.analysis?.diff_image_url; + lines.push( + `- ${getComparisonName(comparison)}: ${getComparisonDisplayState(comparison)}${detail}` + ); + if (diffUrl) { + lines.push(` Diff: ${diffUrl}`); + } + } + } + + if (comparisons.length > 0 && changed.length === 0 && fresh.length === 0) { + lines.push(''); + lines.push('## Reviewed Screenshots'); + + for (let comparison of comparisons.slice(0, 10)) { + lines.push( + `- ${getComparisonName(comparison)}: ${getComparisonDisplayState(comparison)}` + ); + } + + if (comparisons.length > 10) { + lines.push(`- ...${comparisons.length - 10} more`); + } + } + + lines.push(''); + lines.push( + 'Use this as reviewed UI context. Treat approved baselines as visual truth, inspect meaningful diffs, and leave approval decisions to humans.' + ); + + return lines.join('\n'); +} + function countScreenshotCommentEntries(groups = []) { return groups.reduce( (total, group) => total + (group.comments?.length || 0), @@ -515,6 +698,12 @@ export async function contextBuildCommand( return; } + if (options.agent) { + output.print(formatAgentBuildContext(context)); + output.cleanup(); + return; + } + displayBuildContext(output, context); output.cleanup(); } catch (error) { diff --git a/src/commands/login.js b/src/commands/login.js index d0e2c47..7f7b47d 100644 --- a/src/commands/login.js +++ b/src/commands/login.js @@ -261,7 +261,9 @@ export async function loginCommand( } output.blank(); - output.hint('You can now use Vizzly CLI commands without VIZZLY_TOKEN'); + output.hint( + 'Run "vizzly project link /" to enable cloud uploads' + ); output.cleanup(); } catch (error) { diff --git a/src/commands/project.js b/src/commands/project.js new file mode 100644 index 0000000..525d689 --- /dev/null +++ b/src/commands/project.js @@ -0,0 +1,144 @@ +import { createApiClient as defaultCreateApiClient } from '../api/client.js'; +import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js'; +import { getApiUrl as defaultGetApiUrl } from '../utils/environment-config.js'; +import { getAccessToken as defaultGetAccessToken } from '../utils/global-config.js'; +import * as defaultOutput from '../utils/output.js'; +import { saveProjectLink as defaultSaveProjectLink } from '../utils/project-link-store.js'; + +export function parseProjectSelector(selector, options = {}) { + let organizationSlug = options.org || null; + let projectSlug = options.project || null; + + if (selector) { + let parts = selector.split('/'); + if (parts.length === 2) { + organizationSlug = organizationSlug || parts[0]; + projectSlug = projectSlug || parts[1]; + } else if (parts.length === 1) { + projectSlug = projectSlug || parts[0]; + } + } + + return { organizationSlug, projectSlug }; +} + +export function validateProjectLinkOptions(selector, options = {}) { + let errors = []; + let { organizationSlug, projectSlug } = parseProjectSelector( + selector, + options + ); + + if (!organizationSlug) { + errors.push( + 'Organization is required. Use / or --org .' + ); + } + if (!projectSlug) { + errors.push( + 'Project is required. Use / or --project .' + ); + } + + return errors; +} + +export async function projectLinkCommand( + selector, + options = {}, + globalOptions = {}, + deps = {} +) { + let { + createApiClient = defaultCreateApiClient, + getAccessToken = defaultGetAccessToken, + getApiUrl = defaultGetApiUrl, + loadConfig = defaultLoadConfig, + output = defaultOutput, + saveProjectLink = defaultSaveProjectLink, + exit = code => process.exit(code), + } = deps; + + output.configure({ + json: globalOptions.json, + verbose: globalOptions.verbose, + color: !globalOptions.noColor, + }); + + let { organizationSlug, projectSlug } = parseProjectSelector( + selector, + options + ); + + try { + let config = await loadConfig(globalOptions.config, globalOptions); + let userToken = config.userToken || (await getAccessToken()); + + if (!userToken) { + output.error('Login required before linking a project'); + output.hint('Run "vizzly login" first, then try project link again'); + output.cleanup(); + exit(1); + return; + } + + output.startSpinner(`Linking ${organizationSlug}/${projectSlug}...`); + + let apiUrl = config.apiUrl || getApiUrl(); + let client = createApiClient({ + baseUrl: apiUrl, + token: userToken, + command: 'project-link', + }); + + let response = await client.request(`/api/cli/${projectSlug}/link-token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Organization': organizationSlug, + }, + body: JSON.stringify({ + name: options.name, + expiresAt: options.expiresAt, + }), + }); + + let linkedProject = await saveProjectLink({ + apiUrl, + organizationSlug: response.organization?.slug || organizationSlug, + organizationName: response.organization?.name, + projectSlug: response.project?.slug || projectSlug, + projectName: response.project?.name, + token: response.token.token, + tokenId: response.token.id, + tokenPrefix: response.token.token_prefix, + expiresAt: response.token.expires_at, + createdAt: response.token.created_at, + }); + + output.stopSpinner(); + + if (globalOptions.json) { + output.data({ + linked: true, + organizationSlug: linkedProject.organizationSlug, + projectSlug: linkedProject.projectSlug, + tokenPrefix: linkedProject.tokenPrefix, + storage: linkedProject.storage, + }); + output.cleanup(); + return; + } + + output.complete( + `Linked ${linkedProject.organizationSlug}/${linkedProject.projectSlug}` + ); + output.hint(`Cloud uploads will use ${linkedProject.tokenPrefix}...`); + output.cleanup(); + } catch (error) { + output.stopSpinner(); + output.error('Failed to link project', error); + output.cleanup(); + exit(1); + } +} diff --git a/src/commands/review.js b/src/commands/review.js index b93c9db..c02f63b 100644 --- a/src/commands/review.js +++ b/src/commands/review.js @@ -4,12 +4,14 @@ import { createApiClient as defaultCreateApiClient } from '../api/index.js'; import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js'; +import { getAccessToken as defaultGetAccessToken } from '../utils/global-config.js'; import * as defaultOutput from '../utils/output.js'; function createReviewDeps(deps = {}) { return { loadConfig: deps.loadConfig || defaultLoadConfig, createApiClient: deps.createApiClient || defaultCreateApiClient, + getAccessToken: deps.getAccessToken || defaultGetAccessToken, output: deps.output || defaultOutput, exit: deps.exit || (code => process.exit(code)), }; @@ -28,10 +30,31 @@ async function loadReviewConfig({ loadConfig, options, globalOptions }) { return await loadConfig(globalOptions.config, allOptions); } -function createReviewClient({ createApiClient, config, command }) { +function isProjectToken(token) { + return typeof token === 'string' && token.startsWith('vzt_'); +} + +async function getReviewToken(config, getAccessToken) { + if (config.userToken) { + return config.userToken; + } + + let userToken = await getAccessToken(); + if (userToken) { + return userToken; + } + + if (config.apiKey && !isProjectToken(config.apiKey)) { + return config.apiKey; + } + + return null; +} + +function createReviewClient({ createApiClient, config, command, token }) { return createApiClient({ baseUrl: config.apiUrl, - token: config.apiKey, + token, command, }); } @@ -61,7 +84,8 @@ async function runReviewMutation({ deps, configure = true, }) { - let { loadConfig, createApiClient, output, exit } = createReviewDeps(deps); + let { loadConfig, createApiClient, getAccessToken, output, exit } = + createReviewDeps(deps); if (configure) { configureOutput(output, globalOptions); @@ -69,10 +93,11 @@ async function runReviewMutation({ try { let config = await loadReviewConfig({ loadConfig, options, globalOptions }); + let token = await getReviewToken(config, getAccessToken); - if (!config.apiKey) { - output.error('API token required'); - output.hint('Use --token or set VIZZLY_TOKEN environment variable'); + if (!token) { + output.error('User login required for review actions'); + output.hint('Run "vizzly login" to approve, reject, or comment'); output.cleanup(); exit(1); return; @@ -80,7 +105,12 @@ async function runReviewMutation({ output.startSpinner(spinnerMessage); - let client = createReviewClient({ createApiClient, config, command }); + let client = createReviewClient({ + createApiClient, + config, + command, + token, + }); let response = await client.request(endpoint, { method: 'POST', ...jsonBody(requestBody), diff --git a/src/commands/run.js b/src/commands/run.js index 3185613..d47669e 100644 --- a/src/commands/run.js +++ b/src/commands/run.js @@ -64,6 +64,10 @@ export async function resolveBuildDisplayUrl({ return undefined; } +function buildContextCommand(buildId) { + return `vizzly context build ${buildId} --agent`; +} + /** * Run command implementation * @param {string} testCommand - Test command to execute @@ -363,6 +367,9 @@ export async function runCommand( commit, message, }, + contextCommand: result.buildId + ? buildContextCommand(result.buildId) + : null, exitCode: 0, }; @@ -397,6 +404,10 @@ export async function runCommand( ` ${colors.brand.textTertiary('Build')} ${colors.dim(result.buildId)}` ); } + + output.print( + ` ${colors.brand.textTertiary('Context')} ${colors.dim(buildContextCommand(result.buildId))}` + ); } } catch (error) { // Test execution failed - build should already be finalized by test runner @@ -493,6 +504,7 @@ export async function runCommand( identical: buildResult.identicalComparisons || 0, }, approvalStatus: buildResult.approvalStatus || 'pending', + contextCommand: buildContextCommand(result.buildId), exitCode, }; diff --git a/src/commands/tdd.js b/src/commands/tdd.js index 525c117..148fb45 100644 --- a/src/commands/tdd.js +++ b/src/commands/tdd.js @@ -27,6 +27,10 @@ import { } from '../utils/git.js'; import * as defaultOutput from '../utils/output.js'; +function buildLocalContextCommand() { + return 'vizzly context build current --source local --agent'; +} + /** * TDD command implementation * @param {string} testCommand - Test command to execute @@ -258,10 +262,16 @@ export async function tddCommand( comparisons, summary, reportPath: runResult.reportPath || '.vizzly/report/index.html', + contextCommand: buildLocalContextCommand(), }); output.cleanup(); } + if (!globalOptions.json && !options.daemon) { + output.blank?.(); + output.print(` Context ${buildLocalContextCommand()}`); + } + return { result: { success: !hasFailures, diff --git a/src/context/local-workspace-provider.js b/src/context/local-workspace-provider.js index 38f7a33..4564088 100644 --- a/src/context/local-workspace-provider.js +++ b/src/context/local-workspace-provider.js @@ -1,12 +1,13 @@ -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync as defaultExistsSync, readFileSync } from 'node:fs'; import { basename, isAbsolute, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; 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)) { + if (!defaultExistsSync(path)) { return null; } @@ -89,7 +90,7 @@ function resolveAssetReference(assetPath, snapshot) { return null; } - if (/^https?:\/\//.test(assetPath) || isAbsolute(assetPath)) { + if (/^https?:\/\//.test(assetPath)) { return assetPath; } @@ -101,6 +102,10 @@ function resolveAssetReference(assetPath, snapshot) { return join(snapshot.vizzlyDir, assetPath.replace('/images/', '')); } + if (isAbsolute(assetPath)) { + return assetPath; + } + return assetPath; } @@ -194,6 +199,82 @@ function buildBuildSnapshot(snapshot) { }; } +function buildBaselineSnapshot(snapshot) { + let metadata = snapshot.baselineMetadata; + + if (!metadata) { + return { + selected: null, + selection_reason: 'no_local_baseline_metadata', + comparison_baseline_build_ids: [], + }; + } + + return { + selected: { + id: metadata.buildId || 'local-baseline', + name: metadata.buildName || metadata.buildId || 'Local TDD Baseline', + branch: metadata.branch || 'local', + commit_sha: metadata.buildInfo?.commitSha || null, + commit_message: metadata.buildInfo?.commitMessage || null, + approval_status: metadata.buildInfo?.approvalStatus || 'approved', + status: metadata.buildInfo?.completedAt ? 'completed' : 'local', + created_at: metadata.createdAt || null, + completed_at: metadata.buildInfo?.completedAt || null, + }, + selection_reason: 'local_workspace_baseline_metadata', + comparison_baseline_build_ids: metadata.buildId ? [metadata.buildId] : [], + }; +} + +function buildReviewState(build, reviewSummary) { + let reasons = []; + + if (build.approval_status === 'pending') { + reasons.push('build_pending_approval'); + } + + if (reviewSummary.pending > 0) { + reasons.push('comparisons_need_review'); + } + + return { + needs_review: reasons.length > 0, + reasons, + pending_comparisons: reviewSummary.pending, + unresolved_comments: 0, + }; +} + +function mapLocalScreenshot(snapshot, comparison) { + let mapped = mapLocalComparison(snapshot, comparison); + let baselineBuildId = snapshot.baselineMetadata?.buildId || null; + + return { + id: mapped.screenshot.id, + name: mapped.screenshot.name, + browser: mapped.screenshot.browser, + viewport: { + width: mapped.screenshot.viewport_width, + height: mapped.screenshot.viewport_height, + }, + url: mapped.screenshot.original_url, + baseline: mapped.baseline + ? { + id: mapped.baseline.id, + build_id: baselineBuildId, + name: mapped.baseline.name, + browser: mapped.baseline.browser, + viewport: { + width: mapped.baseline.viewport_width, + height: mapped.baseline.viewport_height, + }, + url: mapped.baseline.original_url, + } + : null, + }; +} + function mapLocalComparison(snapshot, comparison) { let details = snapshot.comparisonDetails[comparison.id] || {}; let comparisonName = comparison.originalName || comparison.name; @@ -205,13 +286,21 @@ function mapLocalComparison(snapshot, comparison) { let hotspotAnalysis = buildHotspotAnalysis(snapshot, comparisonName, details); let properties = comparison.properties || {}; let buildSnapshot = buildBuildSnapshot(snapshot); + let result = mapComparisonResult(comparison.status); + let approvalStatus = mapApprovalStatus(comparison.status); + let baselineBuildId = snapshot.baselineMetadata?.buildId || null; + let diffImageUrl = resolveAssetReference(comparison.diff, snapshot); + let diffRegions = details.diffClusters || []; return { id: comparison.id, name: comparisonName, + screenshot_name: comparisonName, status: comparison.status, - result: mapComparisonResult(comparison.status), - approval_status: mapApprovalStatus(comparison.status), + result, + approval_status: approvalStatus, + needs_review: + approvalStatus === 'pending' && ['changed', 'new'].includes(result), build_id: buildSnapshot.id, build_name: buildSnapshot.name, build_branch: buildSnapshot.branch, @@ -232,6 +321,7 @@ function mapLocalComparison(snapshot, comparison) { baseline: comparison.baseline ? { id: `${comparison.id}-baseline`, + build_id: baselineBuildId, name: comparisonName, browser: properties.browser ?? null, viewport_width: properties.viewport_width ?? null, @@ -239,12 +329,29 @@ function mapLocalComparison(snapshot, comparison) { original_url: resolveAssetReference(comparison.baseline, snapshot), } : null, + diff: { + percentage: comparison.diffPercentage ?? null, + changed_pixels: comparison.diffCount ?? null, + total_pixels: comparison.totalPixels ?? null, + threshold: comparison.threshold ?? null, + image_url: diffImageUrl, + regions: diffRegions, + cluster_metadata: diffRegions.length + ? { + clusterCount: diffRegions.length, + local_workspace: true, + } + : null, + fingerprint_hash: null, + fingerprint_data: null, + diff_lines: [], + }, analysis: { - diff_image_url: resolveAssetReference(comparison.diff, snapshot), - diff_regions: details.diffClusters || [], - cluster_metadata: details.diffClusters + diff_image_url: diffImageUrl, + diff_regions: diffRegions, + cluster_metadata: diffRegions.length ? { - clusterCount: details.diffClusters.length, + clusterCount: diffRegions.length, local_workspace: true, } : null, @@ -283,9 +390,20 @@ function createLocalWorkspaceError(message) { return error; } +function createReportUrl(snapshot) { + let reportPath = join(snapshot.vizzlyDir, 'report', 'index.html'); + + if (!snapshot.existsSync(reportPath)) { + return null; + } + + return pathToFileURL(reportPath).href; +} + export function createLocalWorkspaceContextProvider(options = {}, deps = {}) { let projectRoot = options.projectRoot || process.cwd(); let readJson = deps.readJsonIfExists || readJsonIfExists; + let existsSync = deps.existsSync || defaultExistsSync; let snapshotCache = null; function loadSnapshot() { @@ -297,6 +415,7 @@ export function createLocalWorkspaceContextProvider(options = {}, deps = {}) { snapshotCache = { projectRoot, vizzlyDir, + existsSync, serverInfo: readJson(join(vizzlyDir, 'server.json')), session: readJson(join(vizzlyDir, 'session.json')), reportData: normalizeReportData( @@ -382,8 +501,10 @@ export function createLocalWorkspaceContextProvider(options = {}, deps = {}) { } function createBuildLinks(snapshot) { + let reportUrl = createReportUrl(snapshot); + if (!snapshot.serverInfo?.port) { - return {}; + return reportUrl ? { report_url: reportUrl } : {}; } let buildId = buildBuildSnapshot(snapshot).id; @@ -392,6 +513,7 @@ export function createLocalWorkspaceContextProvider(options = {}, deps = {}) { 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, + ...(reportUrl ? { report_url: reportUrl } : {}), }; } @@ -414,13 +536,19 @@ export function createLocalWorkspaceContextProvider(options = {}, deps = {}) { let mappedComparisons = snapshot.reportData.comparisons.map(comparison => mapLocalComparison(snapshot, comparison) ); + let mappedScreenshots = snapshot.reportData.comparisons.map(comparison => + mapLocalScreenshot(snapshot, comparison) + ); let reviewSummary = buildReviewSummary(snapshot.reportData.comparisons); + let reviewState = buildReviewState(resolvedBuild, reviewSummary); return { resource: 'build_context', source: LOCAL_CONTEXT_SOURCE, scope: createScope(), build: resolvedBuild, + baseline: buildBaselineSnapshot(snapshot), + status: reviewState, summary: { comparisons: { total: mappedComparisons.length, @@ -432,12 +560,21 @@ export function createLocalWorkspaceContextProvider(options = {}, deps = {}) { ).length, }, review: reviewSummary, + comments: { + build: 0, + screenshot: 0, + }, }, review: { comments: [], assignments: [], }, + screenshots: mappedScreenshots, comparisons: mappedComparisons, + comments: { + build: [], + screenshot_count: 0, + }, links: createBuildLinks(snapshot), }; } diff --git a/src/project/core.js b/src/project/core.js index d14fd11..6dfe979 100644 --- a/src/project/core.js +++ b/src/project/core.js @@ -68,8 +68,8 @@ export function buildBuildsUrl(projectSlug, options = {}) { * @param {string} [tokenId] - Optional token ID for specific token operations * @returns {string} API URL path */ -export function buildTokensUrl(organizationSlug, projectSlug, tokenId) { - let base = `/api/cli/organizations/${organizationSlug}/projects/${projectSlug}/tokens`; +export function buildTokensUrl(_organizationSlug, projectSlug, tokenId) { + let base = `/api/project/${projectSlug}/tokens`; return tokenId ? `${base}/${tokenId}` : base; } diff --git a/src/project/operations.js b/src/project/operations.js index d35afc7..4c32f56 100644 --- a/src/project/operations.js +++ b/src/project/operations.js @@ -330,7 +330,10 @@ export async function createProjectToken( buildTokensUrl(organizationSlug, projectSlug), { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...buildOrgHeader(organizationSlug), + }, body: JSON.stringify(tokenData), } ); @@ -361,6 +364,7 @@ export async function listProjectTokens( buildTokensUrl(organizationSlug, projectSlug), { method: 'GET', + headers: buildOrgHeader(organizationSlug), } ); return extractTokens(response); @@ -392,6 +396,7 @@ export async function revokeProjectToken( buildTokensUrl(organizationSlug, projectSlug, tokenId), { method: 'DELETE', + headers: buildOrgHeader(organizationSlug), } ); } catch (error) { diff --git a/src/reporter/src/components/code-block.jsx b/src/reporter/src/components/code-block.jsx index 7cdd30b..489518d 100644 --- a/src/reporter/src/components/code-block.jsx +++ b/src/reporter/src/components/code-block.jsx @@ -1,8 +1,6 @@ -import { useEffect, useRef } from 'react'; - // Simple tokenizer for different languages function tokenize(code, language) { - const tokens = []; + let tokens = []; if (language === 'shell') { code.split('\n').forEach((line, lineNum) => { @@ -31,7 +29,7 @@ function tokenize(code, language) { } // JavaScript/TypeScript - const patterns = [ + let patterns = [ // Comments { type: 'comment', regex: /\/\/.*$/gm }, { type: 'comment', regex: /\/\*[\s\S]*?\*\//g }, @@ -67,7 +65,7 @@ function tokenize(code, language) { tokens.sort((a, b) => a.start - b.start); // Remove overlaps (keep first token at each position) - const filtered = []; + let filtered = []; let lastEnd = 0; tokens.forEach(token => { if (token.start >= lastEnd) { @@ -79,69 +77,49 @@ function tokenize(code, language) { return filtered; } -export default function CodeBlock({ code, language = 'javascript' }) { - const codeRef = useRef(null); - const isShell = - language === 'shell' || - code.trim().startsWith('#') || - code.trim().startsWith('npm'); - - useEffect(() => { - // Check for browser support - if ( - typeof window === 'undefined' || - !window.CSS?.highlights || - !codeRef.current - ) - return; - - const textNode = codeRef.current.firstChild; - if (!textNode || textNode.nodeType !== window.Node.TEXT_NODE) return; +function getTokenClass(type) { + return `code-token code-token--${type}`; +} - // Tokenize code - const tokens = tokenize(code, isShell ? 'shell' : language); +function renderTokenizedCode(code, language) { + let tokens = tokenize(code, language); + let pieces = []; + let cursor = 0; - // Create ranges for each token - const tokenRanges = tokens.map(token => { - const range = new window.Range(); - range.setStart(textNode, token.start); - range.setEnd(textNode, token.end); - return { type: token.type, range }; - }); + tokens.forEach((token, index) => { + if (token.start > cursor) { + pieces.push(code.slice(cursor, token.start)); + } - // Group ranges by token type - const highlightsByType = new Map(); - tokenRanges.forEach(({ type, range }) => { - if (!highlightsByType.has(type)) { - highlightsByType.set(type, []); - } - highlightsByType.get(type).push(range); - }); + pieces.push( + + {code.slice(token.start, token.end)} + + ); + cursor = token.end; + }); - // Create highlights and register them - const createdHighlights = new Map(); + if (cursor < code.length) { + pieces.push(code.slice(cursor)); + } - for (const [type, ranges] of highlightsByType) { - const highlight = new window.Highlight(...ranges); - createdHighlights.set(type, highlight); - window.CSS.highlights.set(`code-${type}`, highlight); - } + return pieces; +} - // Cleanup function - return () => { - for (const [type] of createdHighlights) { - window.CSS.highlights.delete(`code-${type}`); - } - }; - }, [code, language, isShell]); +export default function CodeBlock({ code, language = 'javascript' }) { + let isShell = + language === 'shell' || + code.trim().startsWith('#') || + code.trim().startsWith('npm'); + let highlightedCode = renderTokenizedCode(code, isShell ? 'shell' : language); return (
-      
-        {code}
+      
+        {highlightedCode}
       
     
); diff --git a/src/reporter/src/reporter.css b/src/reporter/src/reporter.css index 1ebaf92..c46c4cf 100644 --- a/src/reporter/src/reporter.css +++ b/src/reporter/src/reporter.css @@ -8,33 +8,29 @@ Styles specific to the reporter that extend Observatory ============================================ */ -/* ============================================ - SYNTAX HIGHLIGHTING (CSS Custom Highlight API) - ============================================ */ - -::highlight(code-keyword) { +.code-token--keyword { color: #c792ea; font-weight: 500; } -::highlight(code-string) { +.code-token--string { color: #c3e88d; } -::highlight(code-comment) { +.code-token--comment { color: #64748b; font-style: italic; } -::highlight(code-number) { +.code-token--number { color: #f78c6c; } -::highlight(code-function) { +.code-token--function { color: #82aaff; } -::highlight(code-command) { +.code-token--command { color: #f59e0b; } diff --git a/src/server/handlers/tdd-handler.js b/src/server/handlers/tdd-handler.js index 103e360..3bde603 100644 --- a/src/server/handlers/tdd-handler.js +++ b/src/server/handlers/tdd-handler.js @@ -200,6 +200,18 @@ export const createTddHandler = ( const reportPath = join(workingDir, '.vizzly', 'report-data.json'); const detailsPath = join(workingDir, '.vizzly', 'comparison-details.json'); + let clearJsonFile = path => { + if (existsSync(path)) { + unlinkSync(path); + } + }; + + let prepareRunArtifacts = () => { + clearJsonFile(reportPath); + clearJsonFile(detailsPath); + tddService.clearRunData?.(); + }; + /** * Read heavy comparison details from comparison-details.json * Returns a map of comparison ID -> heavy fields @@ -317,6 +329,7 @@ export const createTddHandler = ( const initialize = async () => { output.debug('tdd', 'initializing local mode'); + prepareRunArtifacts(); // In baseline update mode, skip all baseline loading/downloading if (setBaseline) { diff --git a/src/services/test-runner.js b/src/services/test-runner.js index 9f116f8..660893b 100644 --- a/src/services/test-runner.js +++ b/src/services/test-runner.js @@ -18,6 +18,7 @@ import { VizzlyError } from '../errors/vizzly-error.js'; import { cancelTests, createBuild, + fetchBuildUrl, finalizeBuild, initializeDaemon, runTests, @@ -95,6 +96,7 @@ export class TestRunner extends EventEmitter { } async createBuild(options, tdd) { + let createdApiBuild = null; let buildId = await createBuild({ runOptions: options, tdd, @@ -102,12 +104,27 @@ export class TestRunner extends EventEmitter { deps: { buildManager: this.buildManager, createApiClient: this.deps.createApiClient, - createApiBuild: this.deps.createApiBuild, + createApiBuild: async (client, payload) => { + createdApiBuild = await this.deps.createApiBuild(client, payload); + return createdApiBuild; + }, output: this.deps.output, }, }); if (!tdd && buildId) { + let buildUrl = + createdApiBuild?.url || + (await fetchBuildUrl({ + buildId, + config: this.config, + deps: { + createApiClient: this.deps.createApiClient, + getBuild: this.deps.getBuild, + output: this.deps.output, + }, + })); + let writeSession = this.deps.writeSession || defaultWriteSession; writeSession({ buildId, @@ -115,6 +132,8 @@ export class TestRunner extends EventEmitter { commit: options?.commit, parallelId: options?.parallelId, }); + + this.emit('build-created', { buildId, url: buildUrl }); } return buildId; diff --git a/src/tdd/services/baseline-manager.js b/src/tdd/services/baseline-manager.js index d921891..054a543 100644 --- a/src/tdd/services/baseline-manager.js +++ b/src/tdd/services/baseline-manager.js @@ -52,6 +52,22 @@ export function clearBaselineData(paths) { } } +/** + * Clear per-run screenshot artifacts while preserving approved baselines. + * + * @param {{ currentPath: string, diffPath: string }} paths + */ +export function clearRunData(paths) { + let { currentPath, diffPath } = paths; + + for (let dir of [currentPath, diffPath]) { + if (existsSync(dir)) { + rmSync(dir, { recursive: true, force: true }); + } + mkdirSync(dir, { recursive: true }); + } +} + /** * Save an image as baseline * diff --git a/src/tdd/tdd-service.js b/src/tdd/tdd-service.js index 264b32d..44a1e17 100644 --- a/src/tdd/tdd-service.js +++ b/src/tdd/tdd-service.js @@ -64,6 +64,7 @@ import { import { baselineExists as defaultBaselineExists, clearBaselineData as defaultClearBaselineData, + clearRunData as defaultClearRunData, getBaselinePath as defaultGetBaselinePath, getCurrentPath as defaultGetCurrentPath, getDiffPath as defaultGetDiffPath, @@ -148,6 +149,7 @@ export class TddService { baseline: { baselineExists: defaultBaselineExists, clearBaselineData: defaultClearBaselineData, + clearRunData: defaultClearRunData, getBaselinePath: defaultGetBaselinePath, getCurrentPath: defaultGetCurrentPath, getDiffPath: defaultGetDiffPath, @@ -226,6 +228,13 @@ export class TddService { } } + clearRunData() { + this._deps.clearRunData({ + currentPath: this.currentPath, + diffPath: this.diffPath, + }); + } + /** * Download baselines from cloud */ diff --git a/src/utils/config-loader.js b/src/utils/config-loader.js index 6bb63fb..f464e63 100644 --- a/src/utils/config-loader.js +++ b/src/utils/config-loader.js @@ -10,6 +10,7 @@ import { } from './environment-config.js'; import { getAccessToken } from './global-config.js'; import * as output from './output.js'; +import { getActiveProjectLink } from './project-link-store.js'; export async function loadConfig(configPath = null, cliOverrides = {}) { // 1. Load from config file using cosmiconfig @@ -55,17 +56,34 @@ export async function loadConfig(configPath = null, cliOverrides = {}) { applyCLIOverrides(config, cliOverrides); - // 5. Fall back to user auth token if no other token found - // This enables interactive commands (builds, comparisons, approve, etc.) - // to work without a project token when the user is logged in + // 5. Fall back to a linked project token for cloud upload commands. if (!config.apiKey) { - let userToken = await getAccessToken(); - if (userToken) { - config.apiKey = userToken; - output.debug('config', 'using token from user login'); + let linkedProject = await getActiveProjectLink({ apiUrl: config.apiUrl }); + if (linkedProject?.token) { + config.apiKey = linkedProject.token; + config.linkedProject = { + account: linkedProject.account, + apiUrl: linkedProject.apiUrl, + organizationSlug: linkedProject.organizationSlug, + organizationName: linkedProject.organizationName, + projectSlug: linkedProject.projectSlug, + projectName: linkedProject.projectName, + tokenId: linkedProject.tokenId, + tokenPrefix: linkedProject.tokenPrefix, + expiresAt: linkedProject.expiresAt, + storage: linkedProject.storage, + }; + output.debug('config', 'using linked project token'); } } + // 6. Keep user auth separate from upload credentials. + let userToken = await getAccessToken(); + if (userToken) { + config.userToken = userToken; + output.debug('config', 'using user login for user-authenticated commands'); + } + return config; } diff --git a/src/utils/project-link-store.js b/src/utils/project-link-store.js new file mode 100644 index 0000000..4b57c9b --- /dev/null +++ b/src/utils/project-link-store.js @@ -0,0 +1,129 @@ +import { loadGlobalConfig, saveGlobalConfig } from './global-config.js'; +import { + deleteSecret as defaultDeleteSecret, + getSecret as defaultGetSecret, + saveSecret as defaultSaveSecret, +} from './secret-store.js'; + +export function buildProjectLinkAccount({ + apiUrl, + organizationSlug, + projectSlug, +}) { + return `${apiUrl || 'https://app.vizzly.dev'}|${organizationSlug}/${projectSlug}`; +} + +function getProjectLinkConfig(config) { + return config.projectLink || { active: null, links: {} }; +} + +export async function saveProjectLink(link, deps = {}) { + let { + loadConfig = loadGlobalConfig, + saveConfig = saveGlobalConfig, + saveSecret = defaultSaveSecret, + now = () => new Date(), + } = deps; + + let config = await loadConfig(); + let projectLink = getProjectLinkConfig(config); + let account = buildProjectLinkAccount(link); + let storedInKeychain = await saveSecret(account, link.token); + + projectLink.active = account; + projectLink.links = { + ...projectLink.links, + [account]: { + apiUrl: link.apiUrl, + organizationSlug: link.organizationSlug, + organizationName: link.organizationName, + projectSlug: link.projectSlug, + projectName: link.projectName, + tokenId: link.tokenId, + tokenPrefix: link.tokenPrefix, + expiresAt: link.expiresAt || null, + createdAt: link.createdAt || now().toISOString(), + storage: storedInKeychain ? 'keychain' : 'file', + token: storedInKeychain ? undefined : link.token, + }, + }; + + config.projectLink = projectLink; + await saveConfig(config); + + return { + ...projectLink.links[account], + account, + token: link.token, + }; +} + +export async function getActiveProjectLink(options = {}, deps = {}) { + let { loadConfig = loadGlobalConfig, getSecret = defaultGetSecret } = deps; + + let config = await loadConfig(); + let projectLink = getProjectLinkConfig(config); + let account = options.account || projectLink.active; + let link = account ? projectLink.links?.[account] : null; + + if ( + !link && + options.apiUrl && + options.organizationSlug && + options.projectSlug + ) { + account = buildProjectLinkAccount(options); + link = projectLink.links?.[account] || null; + } + + if (!link) { + return null; + } + + if (options.apiUrl && link.apiUrl && link.apiUrl !== options.apiUrl) { + return null; + } + + let token = + link.storage === 'keychain' ? await getSecret(account) : link.token; + if (!token) { + return null; + } + + return { + ...link, + account, + token, + }; +} + +export async function clearActiveProjectLink(deps = {}) { + let { + loadConfig = loadGlobalConfig, + saveConfig = saveGlobalConfig, + deleteSecret = defaultDeleteSecret, + } = deps; + + let config = await loadConfig(); + let projectLink = getProjectLinkConfig(config); + let account = projectLink.active; + + if (!account || !projectLink.links?.[account]) { + return null; + } + + let link = projectLink.links[account]; + if (link.storage === 'keychain') { + await deleteSecret(account); + } + + delete projectLink.links[account]; + projectLink.active = null; + config.projectLink = projectLink; + await saveConfig(config); + + return { + ...link, + account, + }; +} diff --git a/src/utils/secret-store.js b/src/utils/secret-store.js new file mode 100644 index 0000000..dd19e57 --- /dev/null +++ b/src/utils/secret-store.js @@ -0,0 +1,103 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +let execFileAsync = promisify(execFile); +let KEYCHAIN_SERVICE = 'vizzly-cli'; + +export function canUseKeychain(platform = process.platform, env = process.env) { + return platform === 'darwin' && env.VIZZLY_DISABLE_KEYCHAIN !== 'true'; +} + +export async function saveSecret(account, secret, options = {}) { + let { + service = KEYCHAIN_SERVICE, + platform = process.platform, + env = process.env, + execFileFn = execFileAsync, + } = options; + + if (!canUseKeychain(platform, env)) { + return false; + } + + try { + try { + await execFileFn('security', [ + 'delete-generic-password', + '-s', + service, + '-a', + account, + ]); + } catch { + // Missing secrets are fine; add-generic-password creates the current value. + } + + await execFileFn('security', [ + 'add-generic-password', + '-s', + service, + '-a', + account, + '-w', + secret, + '-U', + ]); + return true; + } catch { + return false; + } +} + +export async function getSecret(account, options = {}) { + let { + service = KEYCHAIN_SERVICE, + platform = process.platform, + env = process.env, + execFileFn = execFileAsync, + } = options; + + if (!canUseKeychain(platform, env)) { + return null; + } + + try { + let { stdout } = await execFileFn('security', [ + 'find-generic-password', + '-s', + service, + '-a', + account, + '-w', + ]); + return stdout.trim() || null; + } catch { + return null; + } +} + +export async function deleteSecret(account, options = {}) { + let { + service = KEYCHAIN_SERVICE, + platform = process.platform, + env = process.env, + execFileFn = execFileAsync, + } = options; + + if (!canUseKeychain(platform, env)) { + return false; + } + + try { + await execFileFn('security', [ + 'delete-generic-password', + '-s', + service, + '-a', + account, + ]); + return true; + } catch { + return false; + } +} diff --git a/tests/api/client.test.js b/tests/api/client.test.js index 2323769..0d2de51 100644 --- a/tests/api/client.test.js +++ b/tests/api/client.test.js @@ -1,6 +1,10 @@ import assert from 'node:assert'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { afterEach, beforeEach, describe, it, mock } from 'node:test'; import { createApiClient, DEFAULT_API_URL } from '../../src/api/client.js'; +import { saveAuthTokens } from '../../src/utils/global-config.js'; describe('api/client', () => { describe('DEFAULT_API_URL', () => { @@ -112,15 +116,26 @@ describe('api/client', () => { describe('request', () => { let originalFetch; let mockFetch; + let originalVizzlyHome; + let tempHome; beforeEach(() => { originalFetch = globalThis.fetch; + originalVizzlyHome = process.env.VIZZLY_HOME; + tempHome = mkdtempSync(join(tmpdir(), 'vizzly-api-client-test-')); + process.env.VIZZLY_HOME = tempHome; mockFetch = mock.fn(); globalThis.fetch = mockFetch; }); afterEach(() => { globalThis.fetch = originalFetch; + if (originalVizzlyHome) { + process.env.VIZZLY_HOME = originalVizzlyHome; + } else { + delete process.env.VIZZLY_HOME; + } + rmSync(tempHome, { recursive: true, force: true }); }); it('makes request to correct URL', async () => { @@ -188,6 +203,31 @@ describe('api/client', () => { ); }); + it('does not exchange user refresh tokens for failed project-token requests', async () => { + await saveAuthTokens({ + accessToken: 'user-access-token', + refreshToken: 'user-refresh-token', + expiresAt: '2999-01-01T00:00:00.000Z', + }); + + let client = createApiClient({ + token: 'vzt_project_token', + baseUrl: 'https://api.test', + }); + + mockFetch.mock.mockImplementation(async () => ({ + ok: false, + status: 401, + headers: new Map(), + text: async () => 'Unauthorized', + })); + + await assert.rejects(() => client.request('/api/sdk/builds'), { + name: 'AuthError', + }); + assert.strictEqual(mockFetch.mock.calls.length, 1); + }); + it('throws VizzlyError for server errors', async () => { let client = createApiClient({ token: 'test-token', diff --git a/tests/commands/context-cli.test.js b/tests/commands/context-cli.test.js index f0f7559..f92bfda 100644 --- a/tests/commands/context-cli.test.js +++ b/tests/commands/context-cli.test.js @@ -146,7 +146,7 @@ describe('context CLI integration', () => { assert.notStrictEqual(result.code, 0); assert.ok(!result.stderr.includes("unknown command 'build'")); - assert.ok(result.stderr.includes('API token required')); + assert.ok(result.stderr.includes('vizzly login')); }); it('reads local build context without requiring an API token', async () => { diff --git a/tests/commands/context.test.js b/tests/commands/context.test.js index d2caf73..e01062c 100644 --- a/tests/commands/context.test.js +++ b/tests/commands/context.test.js @@ -239,6 +239,271 @@ describe('commands/context', () => { assert.ok(printLines.some(line => line.includes('Dashboard CHANGED'))); assert.ok(printLines.some(line => line.includes('Settings NEW'))); }); + + it('renders canonical build context from the app without legacy review fields', async () => { + let output = createMockOutput(); + + await contextBuildCommand( + 'build-1', + {}, + {}, + { + loadConfig: async () => ({ + apiKey: 'token', + apiUrl: 'https://api.test', + }), + createApiClient: () => ({}), + getBuildContext: async () => ({ + resource: 'build_context', + scope: { + organization: { slug: 'acme' }, + project: { slug: 'storybook' }, + }, + build: { + id: 'build-1', + name: 'Context Store Build', + status: 'completed', + }, + baseline: { + selected: { + id: 'baseline-build', + name: 'Approved Main', + approval_status: 'approved', + }, + selection_reason: 'common_ancestor', + }, + status: { + needs_review: true, + pending_comparisons: 1, + unresolved_comments: 2, + }, + summary: { + review: { pending: 1, approved: 4, rejected: 0 }, + comments: { build: 1, screenshot: 2 }, + }, + preview: { status: 'ready', url: 'https://preview.test' }, + screenshots: [{ id: 'ss-1', name: 'Dashboard' }], + comparisons: [ + { + id: 'cmp-1', + screenshot_name: 'Dashboard', + result: 'changed', + needs_review: true, + diff: { + percentage: 0.42, + fingerprint_hash: 'fp-dashboard', + }, + }, + ], + comments: { + build: [{ id: 'comment-1' }], + screenshot_count: 2, + }, + links: { build_url: 'https://app.test/acme/storybook/builds/1' }, + }), + output, + exit: () => {}, + } + ); + + let labels = output.calls.filter(call => call.method === 'labelValue'); + assert.ok( + labels.some( + call => + call.args[0] === 'Baseline' && + call.args[1].includes('Approved Main') + ) + ); + assert.ok( + labels.some( + call => + call.args[0] === 'Needs Review' && call.args[1].includes('yes') + ) + ); + + 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('needs review'))); + }); + + it('prints compact agent context for local and cloud build handoff', async () => { + let output = createMockOutput(); + + await contextBuildCommand( + 'current', + { source: 'local', agent: true }, + {}, + { + loadConfig: async () => ({ + apiUrl: 'https://api.test', + }), + resolveContextSource: () => 'local', + createLocalWorkspaceContextProvider: () => ({ + getBuildContext: async () => ({ + resource: 'build_context', + source: 'local_workspace', + scope: { + organization: { slug: 'local' }, + project: { slug: 'web' }, + }, + build: { + id: 'local-build', + name: 'local-build', + status: 'completed', + }, + baseline: { + selected: { + id: 'baseline-build', + name: 'Approved Main', + approval_status: 'approved', + }, + }, + status: { needs_review: true, pending_comparisons: 1 }, + links: { + report_url: + 'file:///tmp/vizzly-local-workspace/.vizzly/report/index.html', + }, + comparisons: [ + { + screenshot_name: 'Dashboard', + result: 'changed', + diff: { + percentage: 1.2, + image_url: '/images/diffs/dashboard.png', + }, + }, + ], + }), + }), + output, + exit: () => {}, + } + ); + + let agentOutput = output.calls + .filter(call => call.method === 'print') + .map(call => call.args[0]) + .join('\n'); + + assert.ok(agentOutput.includes('Vizzly Visual Context')); + assert.ok(agentOutput.includes('Approved baseline: Approved Main')); + assert.ok(agentOutput.includes('Report: file:///tmp/vizzly-local')); + assert.ok(agentOutput.includes('Dashboard: changed')); + assert.ok(agentOutput.includes('approved baselines as visual truth')); + }); + + it('prints reviewed screenshot names for all-green agent context', async () => { + let output = createMockOutput(); + + await contextBuildCommand( + 'current', + { source: 'local', agent: true }, + {}, + { + loadConfig: async () => ({ + apiUrl: 'https://api.test', + }), + resolveContextSource: () => 'local', + createLocalWorkspaceContextProvider: () => ({ + getBuildContext: async () => ({ + resource: 'build_context', + source: 'local_workspace', + scope: { + organization: { slug: 'local' }, + project: { slug: 'web' }, + }, + build: { + id: 'local-build', + name: 'local-build', + status: 'completed', + }, + baseline: { + selected: { + id: 'baseline-build', + name: 'Approved Main', + approval_status: 'approved', + }, + }, + status: { needs_review: false, pending_comparisons: 0 }, + comparisons: [ + { + screenshot_name: 'Dashboard', + result: 'identical', + approval_status: 'approved', + }, + { + screenshot_name: 'Settings', + result: 'identical', + approval_status: 'approved', + }, + ], + }), + }), + output, + exit: () => {}, + } + ); + + let agentOutput = output.calls + .filter(call => call.method === 'print') + .map(call => call.args[0]) + .join('\n'); + + assert.ok(agentOutput.includes('## Reviewed Screenshots')); + assert.ok(agentOutput.includes('Dashboard: identical')); + assert.ok(agentOutput.includes('Settings: identical')); + }); + + it('includes status-only failed comparisons in agent evidence', async () => { + let output = createMockOutput(); + + await contextBuildCommand( + 'build-1', + { agent: true }, + {}, + { + loadConfig: async () => ({ + apiKey: 'token', + apiUrl: 'https://api.test', + }), + createApiClient: () => ({}), + getBuildContext: async () => ({ + resource: 'build_context', + scope: { + organization: { slug: 'acme' }, + project: { slug: 'web' }, + }, + build: { + id: 'build-1', + name: 'build-1', + status: 'completed', + }, + status: { needs_review: true, pending_comparisons: 1 }, + comparisons: [ + { + screenshot_name: 'Checkout', + status: 'failed', + needs_review: true, + diff_percentage: 0.8, + }, + ], + }), + output, + exit: () => {}, + } + ); + + let agentOutput = output.calls + .filter(call => call.method === 'print') + .map(call => call.args[0]) + .join('\n'); + + assert.ok(agentOutput.includes('## Evidence To Inspect')); + assert.ok(agentOutput.includes('Checkout: failed')); + assert.ok(agentOutput.includes('0.8% diff')); + }); }); describe('contextComparisonCommand', () => { diff --git a/tests/commands/project.test.js b/tests/commands/project.test.js new file mode 100644 index 0000000..17cc0e3 --- /dev/null +++ b/tests/commands/project.test.js @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + parseProjectSelector, + projectLinkCommand, + validateProjectLinkOptions, +} from '../../src/commands/project.js'; + +function createOutput() { + let calls = []; + return { + calls, + configure: options => calls.push({ method: 'configure', args: [options] }), + startSpinner: message => + calls.push({ method: 'startSpinner', args: [message] }), + stopSpinner: () => calls.push({ method: 'stopSpinner', args: [] }), + complete: message => calls.push({ method: 'complete', args: [message] }), + hint: message => calls.push({ method: 'hint', args: [message] }), + error: (message, error) => + calls.push({ method: 'error', args: [message, error] }), + data: value => calls.push({ method: 'data', args: [value] }), + cleanup: () => calls.push({ method: 'cleanup', args: [] }), + }; +} + +describe('commands/project', () => { + describe('parseProjectSelector', () => { + it('parses org/project shorthand and option fallbacks', () => { + assert.deepStrictEqual(parseProjectSelector('vizzly/storybook'), { + organizationSlug: 'vizzly', + projectSlug: 'storybook', + }); + assert.deepStrictEqual( + parseProjectSelector('storybook', { org: 'vizzly' }), + { + organizationSlug: 'vizzly', + projectSlug: 'storybook', + } + ); + }); + }); + + describe('validateProjectLinkOptions', () => { + it('requires both organization and project', () => { + assert.deepStrictEqual( + validateProjectLinkOptions('vizzly/storybook'), + [] + ); + assert.deepStrictEqual( + validateProjectLinkOptions(null, { org: 'vizzly' }), + ['Project is required. Use / or --project .'] + ); + }); + }); + + describe('projectLinkCommand', () => { + it('links a project with user auth and stores only the scoped upload credential', async () => { + let output = createOutput(); + let capturedRequest = null; + let savedLink = null; + + await projectLinkCommand( + 'vizzly/storybook', + { name: 'Local Link' }, + {}, + { + output, + loadConfig: async () => ({ + apiUrl: 'https://app.vizzly.dev', + userToken: 'user-jwt', + }), + getAccessToken: async () => { + throw new Error('config user token should be used first'); + }, + createApiClient: ({ token }) => ({ + request: async (endpoint, options) => { + capturedRequest = { endpoint, options, token }; + return { + organization: { slug: 'vizzly', name: 'Vizzly' }, + project: { slug: 'storybook', name: 'Storybook' }, + token: { + id: 'token-id', + token: 'vzt_secret', + token_prefix: 'vzt_sec', + created_at: '2026-05-20T12:00:00.000Z', + expires_at: null, + }, + }; + }, + }), + saveProjectLink: async link => { + savedLink = link; + return { + ...link, + storage: 'keychain', + }; + }, + } + ); + + assert.strictEqual(capturedRequest.token, 'user-jwt'); + assert.strictEqual( + capturedRequest.endpoint, + '/api/cli/storybook/link-token' + ); + assert.strictEqual( + capturedRequest.options.headers['X-Organization'], + 'vizzly' + ); + assert.deepStrictEqual(JSON.parse(capturedRequest.options.body), { + name: 'Local Link', + }); + assert.strictEqual(savedLink.token, 'vzt_secret'); + assert.strictEqual(savedLink.organizationSlug, 'vizzly'); + assert.strictEqual(savedLink.projectSlug, 'storybook'); + assert.ok( + output.calls.some( + call => + call.method === 'complete' && + call.args[0] === 'Linked vizzly/storybook' + ) + ); + }); + + it('asks the user to login before linking', async () => { + let output = createOutput(); + let exitCode = null; + + await projectLinkCommand( + 'vizzly/storybook', + {}, + {}, + { + output, + loadConfig: async () => ({ apiUrl: 'https://app.vizzly.dev' }), + getAccessToken: async () => null, + exit: code => { + exitCode = code; + }, + } + ); + + assert.strictEqual(exitCode, 1); + assert.ok( + output.calls.some( + call => + call.method === 'hint' && + call.args[0] === + 'Run "vizzly login" first, then try project link again' + ) + ); + }); + }); +}); diff --git a/tests/commands/review.test.js b/tests/commands/review.test.js index c06dc45..f135227 100644 --- a/tests/commands/review.test.js +++ b/tests/commands/review.test.js @@ -50,9 +50,10 @@ function createReviewHarness(response = {}) { }, deps: { loadConfig: async () => ({ - apiKey: 'token-123', + userToken: 'token-123', apiUrl: 'https://api.example.test', }), + getAccessToken: async () => null, createApiClient: config => { clientConfig = config; return { @@ -299,6 +300,7 @@ describe('commands/review', () => { {}, { loadConfig: async () => ({ apiUrl: 'https://api.example.test' }), + getAccessToken: async () => null, output, exit: code => { exitCode = code; @@ -325,9 +327,10 @@ describe('commands/review', () => { { json: true }, { loadConfig: async () => ({ - apiKey: 'token-123', + userToken: 'token-123', apiUrl: 'https://api.example.test', }), + getAccessToken: async () => null, createApiClient: () => ({ request: async () => { throw error; diff --git a/tests/commands/run.test.js b/tests/commands/run.test.js index 69a2151..1039047 100644 --- a/tests/commands/run.test.js +++ b/tests/commands/run.test.js @@ -294,6 +294,13 @@ describe('commands/run', () => { assert.ok(output.calls.some(c => c.method === 'complete')); // Now uses print for screenshot summary assert.ok(output.calls.some(c => c.method === 'print')); + assert.ok( + output.calls.some( + c => + c.method === 'print' && + c.args[0].includes('vizzly context build build-123 --agent') + ) + ); }); it('handles test command failure with exit code', async () => { diff --git a/tests/commands/tdd.test.js b/tests/commands/tdd.test.js index 1f59233..add8323 100644 --- a/tests/commands/tdd.test.js +++ b/tests/commands/tdd.test.js @@ -232,7 +232,15 @@ describe('commands/tdd', () => { assert.strictEqual(result.success, true); assert.strictEqual(result.exitCode, 0); - // Summary output is handled by printResults() in tdd-service.js + assert.ok( + output.calls.some( + c => + c.method === 'print' && + c.args[0].includes( + 'vizzly context build current --source local --agent' + ) + ) + ); }); it('handles test run with failed comparisons', async () => { diff --git a/tests/context/local-workspace-provider.test.js b/tests/context/local-workspace-provider.test.js index 2278ab4..b275e49 100644 --- a/tests/context/local-workspace-provider.test.js +++ b/tests/context/local-workspace-provider.test.js @@ -61,6 +61,52 @@ describe('context/local-workspace-provider', () => { ); }); + it('resolves dashboard image URLs to local files when no server is running', () => { + let projectRoot = '/tmp/vizzly-local-files'; + let paths = createWorkspacePaths(projectRoot); + + let provider = createLocalWorkspaceContextProvider( + { projectRoot }, + { + readJsonIfExists: path => { + if (path === paths.report) { + return { + comparisons: [ + { + id: 'comp-1', + name: 'Dashboard', + originalName: 'Dashboard', + status: 'failed', + current: '/images/current/dashboard.png', + baseline: '/images/baselines/dashboard.png', + diff: '/images/diffs/dashboard.png', + properties: {}, + }, + ], + }; + } + + if (path === paths.comparisonDetails) { + return {}; + } + + return null; + }, + } + ); + + let context = provider.getComparisonContext('comp-1'); + + assert.strictEqual( + context.comparison.diff.image_url, + join(projectRoot, '.vizzly', 'diffs', 'dashboard.png') + ); + assert.strictEqual( + context.comparison.screenshot.original_url, + join(projectRoot, '.vizzly', 'current', 'dashboard.png') + ); + }); + it('reuses one snapshot across availability and lookup calls', () => { let projectRoot = '/tmp/vizzly-local-cache'; let paths = createWorkspacePaths(projectRoot); @@ -145,4 +191,124 @@ describe('context/local-workspace-provider', () => { assert.strictEqual(context.summary.total, 80); assert.strictEqual(context.comparisons.length, 50); }); + + it('exposes the static report URL when a local report exists', () => { + let projectRoot = '/tmp/vizzly-local-report'; + let paths = createWorkspacePaths(projectRoot); + let reportHtmlPath = join(projectRoot, '.vizzly', 'report', 'index.html'); + + let provider = createLocalWorkspaceContextProvider( + { projectRoot }, + { + existsSync: path => path === reportHtmlPath, + readJsonIfExists: path => { + if (path === paths.report) { + return { + comparisons: [ + { + id: 'comp-1', + name: 'Dashboard', + originalName: 'Dashboard', + status: 'passed', + current: '/images/current/dashboard.png', + baseline: '/images/baselines/dashboard.png', + diff: null, + properties: {}, + }, + ], + }; + } + + if (path === paths.comparisonDetails) { + return {}; + } + + return null; + }, + } + ); + + let context = provider.getBuildContext('current'); + + assert.strictEqual( + context.links.report_url, + 'file:///tmp/vizzly-local-report/.vizzly/report/index.html' + ); + }); + + it('exposes local baseline truth and review status in build context', () => { + let projectRoot = '/tmp/vizzly-local-baseline-context'; + let paths = createWorkspacePaths(projectRoot); + + let provider = createLocalWorkspaceContextProvider( + { projectRoot }, + { + readJsonIfExists: path => { + if (path === paths.report) { + return { + comparisons: [ + { + id: 'comp-1', + name: 'Dashboard', + originalName: 'Dashboard', + status: 'failed', + current: '/images/current/dashboard.png', + baseline: '/images/baselines/dashboard.png', + diff: '/images/diffs/dashboard.png', + diffPercentage: 1.2, + properties: { + browser: 'firefox', + viewport_width: 1440, + viewport_height: 900, + }, + }, + ], + }; + } + + if (path === paths.baselineMetadata) { + return { + buildId: 'approved-main', + buildName: 'Approved Main', + branch: 'main', + createdAt: '2026-05-20T12:00:00Z', + buildInfo: { + commitSha: 'abc123', + approvalStatus: 'approved', + completedAt: '2026-05-20T12:01:00Z', + }, + }; + } + + if (path === paths.comparisonDetails) { + return { + 'comp-1': { + diffClusters: [ + { + pixelCount: 42, + boundingBox: { x: 10, y: 20, width: 30, height: 40 }, + }, + ], + }, + }; + } + + return null; + }, + } + ); + + let context = provider.getBuildContext('current'); + + assert.strictEqual(context.baseline.selected.id, 'approved-main'); + assert.strictEqual(context.baseline.selected.approval_status, 'approved'); + assert.strictEqual(context.status.needs_review, true); + assert.strictEqual(context.status.pending_comparisons, 1); + assert.strictEqual( + context.screenshots[0].baseline.build_id, + 'approved-main' + ); + assert.strictEqual(context.comparisons[0].needs_review, true); + assert.strictEqual(context.comparisons[0].diff.regions.length, 1); + }); }); diff --git a/tests/project/core.test.js b/tests/project/core.test.js index d4395b6..1285252 100644 --- a/tests/project/core.test.js +++ b/tests/project/core.test.js @@ -82,14 +82,14 @@ describe('project/core', () => { it('builds tokens API URL without token ID', () => { assert.strictEqual( buildTokensUrl('my-org', 'my-project'), - '/api/cli/organizations/my-org/projects/my-project/tokens' + '/api/project/my-project/tokens' ); }); it('builds tokens API URL with token ID', () => { assert.strictEqual( buildTokensUrl('my-org', 'my-project', 'tok_123'), - '/api/cli/organizations/my-org/projects/my-project/tokens/tok_123' + '/api/project/my-project/tokens/tok_123' ); }); }); diff --git a/tests/server/handlers/tdd-handler.test.js b/tests/server/handlers/tdd-handler.test.js index 5c63e96..66e94ef 100644 --- a/tests/server/handlers/tdd-handler.test.js +++ b/tests/server/handlers/tdd-handler.test.js @@ -65,6 +65,10 @@ function createMockTddService(overrides = {}) { async acceptBaseline(comparison) { return overrides.acceptBaseline?.(comparison) ?? { success: true }; } + + clearRunData() { + return overrides.clearRunData?.(); + } }; } @@ -91,6 +95,11 @@ function createMockDeps(overrides = {}) { ((path, content) => { fileSystem[path] = content; }), + unlinkSync: + overrides.unlinkSync ?? + (path => { + delete fileSystem[path]; + }), join: overrides.join ?? ((...parts) => parts.join('/')), resolve: overrides.resolve ?? (path => path.replace('file://', '')), Buffer: overrides.Buffer ?? { @@ -522,6 +531,39 @@ describe('server/handlers/tdd-handler', () => { assert.strictEqual(loadCalled, true); }); + + it('clears stale run artifacts before loading baselines', async () => { + let clearRunCalled = false; + let deps = createMockDeps({ + tddServiceOverrides: { + clearRunData: () => { + clearRunCalled = true; + }, + loadBaseline: () => ({ buildName: 'Local Build' }), + }, + }); + deps._fileSystem['/test/.vizzly/report-data.json'] = JSON.stringify({ + comparisons: [{ id: 'stale-comparison' }], + }); + deps._fileSystem['/test/.vizzly/comparison-details.json'] = + JSON.stringify({ + 'stale-comparison': { diffClusters: [{ x: 1 }] }, + }); + + let handler = createTddHandler({}, '/test', null, null, false, deps); + + await handler.initialize(); + + assert.strictEqual(clearRunCalled, true); + assert.strictEqual( + deps._fileSystem['/test/.vizzly/report-data.json'], + undefined + ); + assert.strictEqual( + deps._fileSystem['/test/.vizzly/comparison-details.json'], + undefined + ); + }); }); describe('handleScreenshot', () => { diff --git a/tests/services/test-runner.test.js b/tests/services/test-runner.test.js index 6d0bab7..98a068b 100644 --- a/tests/services/test-runner.test.js +++ b/tests/services/test-runner.test.js @@ -8,8 +8,14 @@ function createDeps(overrides = {}) { throw new Error('spawn should not be called in createBuild tests'); }, createApiClient: () => ({}), - createApiBuild: async () => ({ id: 'api-build-123' }), - getBuild: async () => ({ id: 'api-build-123' }), + createApiBuild: async () => ({ + id: 'api-build-123', + url: 'https://app.vizzly.dev/org/project/builds/api-build-123', + }), + getBuild: async () => ({ + id: 'api-build-123', + url: 'https://app.vizzly.dev/org/project/builds/api-build-123', + }), finalizeApiBuild: async () => {}, output: { debug: () => {}, @@ -42,6 +48,10 @@ describe('services/test-runner', () => { {}, { deps } ); + let buildEvents = []; + testRunner.on('build-created', event => { + buildEvents.push(event); + }); let buildId = await testRunner.createBuild( { @@ -54,6 +64,12 @@ describe('services/test-runner', () => { ); assert.strictEqual(buildId, 'api-build-123'); + assert.deepStrictEqual(buildEvents, [ + { + buildId: 'api-build-123', + url: 'https://app.vizzly.dev/org/project/builds/api-build-123', + }, + ]); assert.deepStrictEqual(writtenSessions, [ { buildId: 'api-build-123', diff --git a/tests/tdd/services/baseline-manager.test.js b/tests/tdd/services/baseline-manager.test.js index 48265a9..c74ecee 100644 --- a/tests/tdd/services/baseline-manager.test.js +++ b/tests/tdd/services/baseline-manager.test.js @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, it } from 'node:test'; import { baselineExists, clearBaselineData, + clearRunData, getBaselinePath, getCurrentPath, getDiffPath, @@ -86,6 +87,24 @@ describe('tdd/services/baseline-manager', () => { }); }); + describe('clearRunData', () => { + it('removes current and diff artifacts without deleting baselines', () => { + let paths = initializeDirectories(testDir); + + writeFileSync(join(paths.baselinePath, 'approved.png'), 'baseline'); + writeFileSync(join(paths.currentPath, 'stale-current.png'), 'current'); + writeFileSync(join(paths.diffPath, 'stale-diff.png'), 'diff'); + + clearRunData(paths); + + assert.ok(existsSync(join(paths.baselinePath, 'approved.png'))); + assert.ok(existsSync(paths.currentPath)); + assert.ok(existsSync(paths.diffPath)); + assert.ok(!existsSync(join(paths.currentPath, 'stale-current.png'))); + assert.ok(!existsSync(join(paths.diffPath, 'stale-diff.png'))); + }); + }); + describe('saveBaseline', () => { it('saves image buffer to baseline directory', () => { let paths = initializeDirectories(testDir); diff --git a/tests/utils/config-loader.test.js b/tests/utils/config-loader.test.js index 762160b..80a6401 100644 --- a/tests/utils/config-loader.test.js +++ b/tests/utils/config-loader.test.js @@ -6,6 +6,7 @@ import { getScreenshotPaths, loadConfig, } from '../../src/utils/config-loader.js'; +import { saveGlobalConfig } from '../../src/utils/global-config.js'; describe('utils/config-loader', () => { describe('getScreenshotPaths', () => { @@ -139,6 +140,46 @@ describe('utils/config-loader', () => { assert.strictEqual(config.parallelId, 'parallel-123'); }); + it('keeps user login separate from cloud upload credentials', async () => { + await saveGlobalConfig({ + auth: { + accessToken: 'user-access-token', + refreshToken: 'refresh-token', + expiresAt: '2999-01-01T00:00:00.000Z', + }, + }); + + let config = await loadConfig(); + + assert.strictEqual(config.apiKey, undefined); + assert.strictEqual(config.userToken, 'user-access-token'); + }); + + it('uses the active linked project token for cloud upload credentials', async () => { + await saveGlobalConfig({ + projectLink: { + active: 'https://app.vizzly.dev|vizzly/storybook', + links: { + 'https://app.vizzly.dev|vizzly/storybook': { + apiUrl: 'https://app.vizzly.dev', + organizationSlug: 'vizzly', + projectSlug: 'storybook', + tokenId: 'token-id', + tokenPrefix: 'vzt_lin', + storage: 'file', + token: 'vzt_linked_secret', + }, + }, + }, + }); + + let config = await loadConfig(); + + assert.strictEqual(config.apiKey, 'vzt_linked_secret'); + assert.strictEqual(config.linkedProject.organizationSlug, 'vizzly'); + assert.strictEqual(config.linkedProject.projectSlug, 'storybook'); + }); + it('applies VIZZLY_BUILD_NAME environment variable', async () => { process.env.VIZZLY_BUILD_NAME = 'CI Build #123'; diff --git a/tests/utils/project-link-store.test.js b/tests/utils/project-link-store.test.js new file mode 100644 index 0000000..8eb29ac --- /dev/null +++ b/tests/utils/project-link-store.test.js @@ -0,0 +1,144 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + buildProjectLinkAccount, + clearActiveProjectLink, + getActiveProjectLink, + saveProjectLink, +} from '../../src/utils/project-link-store.js'; + +function createConfigStore(initialConfig = {}) { + let config = initialConfig; + + return { + loadConfig: async () => config, + saveConfig: async nextConfig => { + config = nextConfig; + }, + getConfig: () => config, + }; +} + +function createLink(overrides = {}) { + return { + apiUrl: 'https://app.vizzly.dev', + organizationSlug: 'vizzly', + organizationName: 'Vizzly', + projectSlug: 'storybook', + projectName: 'Storybook', + token: 'vzt_linked_secret', + tokenId: 'token-id', + tokenPrefix: 'vzt_lin', + createdAt: '2026-05-20T12:00:00.000Z', + expiresAt: null, + ...overrides, + }; +} + +describe('utils/project-link-store', () => { + it('builds stable account keys scoped to API, org, and project', () => { + assert.strictEqual( + buildProjectLinkAccount({ + apiUrl: 'https://app.vizzly.dev', + organizationSlug: 'vizzly', + projectSlug: 'storybook', + }), + 'https://app.vizzly.dev|vizzly/storybook' + ); + }); + + it('falls back to the config file when the secure store is unavailable', async () => { + let store = createConfigStore(); + let savedLink = await saveProjectLink(createLink(), { + loadConfig: store.loadConfig, + saveConfig: store.saveConfig, + saveSecret: async () => false, + }); + + assert.strictEqual(savedLink.storage, 'file'); + assert.strictEqual(savedLink.token, 'vzt_linked_secret'); + + let config = store.getConfig(); + let account = 'https://app.vizzly.dev|vizzly/storybook'; + assert.strictEqual(config.projectLink.active, account); + assert.strictEqual(config.projectLink.links[account].storage, 'file'); + assert.strictEqual( + config.projectLink.links[account].token, + 'vzt_linked_secret' + ); + + let activeLink = await getActiveProjectLink( + { apiUrl: 'https://app.vizzly.dev' }, + { loadConfig: store.loadConfig } + ); + + assert.strictEqual(activeLink.account, account); + assert.strictEqual(activeLink.token, 'vzt_linked_secret'); + assert.strictEqual(activeLink.organizationSlug, 'vizzly'); + assert.strictEqual(activeLink.projectSlug, 'storybook'); + }); + + it('keeps linked project tokens out of config when the secure store is available', async () => { + let store = createConfigStore(); + let secrets = new Map(); + + let savedLink = await saveProjectLink(createLink(), { + loadConfig: store.loadConfig, + saveConfig: store.saveConfig, + saveSecret: async (account, token) => { + secrets.set(account, token); + return true; + }, + }); + + let account = 'https://app.vizzly.dev|vizzly/storybook'; + let config = store.getConfig(); + assert.strictEqual(savedLink.storage, 'keychain'); + assert.strictEqual(config.projectLink.links[account].storage, 'keychain'); + assert.strictEqual(config.projectLink.links[account].token, undefined); + assert.strictEqual(secrets.get(account), 'vzt_linked_secret'); + + let activeLink = await getActiveProjectLink( + { apiUrl: 'https://app.vizzly.dev' }, + { + loadConfig: store.loadConfig, + getSecret: async requestedAccount => secrets.get(requestedAccount), + } + ); + + assert.strictEqual(activeLink.token, 'vzt_linked_secret'); + }); + + it('clears the active secure-store link and deletes its saved secret', async () => { + let account = 'https://app.vizzly.dev|vizzly/storybook'; + let store = createConfigStore({ + projectLink: { + active: account, + links: { + [account]: { + apiUrl: 'https://app.vizzly.dev', + organizationSlug: 'vizzly', + projectSlug: 'storybook', + storage: 'keychain', + tokenPrefix: 'vzt_lin', + }, + }, + }, + }); + let deletedAccounts = []; + + let clearedLink = await clearActiveProjectLink({ + loadConfig: store.loadConfig, + saveConfig: store.saveConfig, + deleteSecret: async deletedAccount => { + deletedAccounts.push(deletedAccount); + return true; + }, + }); + + assert.strictEqual(clearedLink.account, account); + assert.deepStrictEqual(deletedAccounts, [account]); + assert.strictEqual(store.getConfig().projectLink.active, null); + assert.deepStrictEqual(store.getConfig().projectLink.links, {}); + }); +});