From 9bfd8076713a9622ae0216a87d5c32d767df26f8 Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Sun, 9 Aug 2026 08:27:30 -0500 Subject: [PATCH 1/2] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20simplify=20dead=20code?= =?UTF-8?q?=20and=20test=20boundaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove unused modules and no-op validators, trim implementation-detail tests, and move command coverage to real CLI and HTTP workflows. Keep the resulting suites focused on observable behavior and explicit external dependencies. --- clients/ember/src/index.js | 3 +- clients/ember/src/testem-config.js | 2 - .../ember/tests/unit/testem-config.test.js | 30 +- clients/static-site/src/screenshot.js | 13 +- clients/static-site/tests/concurrency.test.js | 9 - clients/static-site/tests/pool.test.js | 112 +- clients/static-site/tests/screenshot.test.js | 11 +- clients/static-site/tests/tasks.test.js | 72 +- clients/storybook/src/navigation.js | 11 - clients/storybook/src/screenshot.js | 13 +- clients/storybook/tests/concurrency.test.js | 9 - clients/storybook/tests/navigation.test.js | 31 +- clients/storybook/tests/pool.test.js | 112 +- clients/storybook/tests/screenshot.test.js | 36 +- .../storybook/tests/sdk-integration.test.js | 5 - clients/storybook/tests/tasks.test.js | 72 +- src/cli.js | 58 +- src/commands/baselines.js | 7 - src/commands/config-cmd.js | 7 - src/commands/doctor.js | 8 - src/commands/init.js | 19 +- src/commands/login.js | 13 - src/commands/logout.js | 12 - src/commands/orgs.js | 9 - src/commands/whoami.js | 12 - src/config/index.js | 44 - src/plugin-loader.js | 2 +- src/project/index.js | 54 - .../src/hooks/queries/use-auth-queries.js | 11 - .../src/hooks/queries/use-config-queries.js | 32 - src/reporter/src/utils/comparison-helpers.js | 75 - src/reporter/src/utils/constants.js | 12 - src/reporter/src/utils/image-url.js | 2 +- src/server/routers/events.js | 6 +- src/tdd/index.js | 71 - src/tdd/services/baseline-downloader.js | 163 -- src/tdd/services/hotspot-service.js | 47 - src/utils/environment.js | 105 -- tests/api/client.test.js | 12 - tests/auth/client.test.js | 9 - tests/cli/tdd-lifecycle.test.js | 54 + tests/commands/baselines.test.js | 7 - tests/commands/builds.test.js | 113 -- tests/commands/comparisons.test.js | 86 +- tests/commands/config-cmd.test.js | 12 +- tests/commands/context.test.js | 1641 +---------------- tests/commands/doctor.test.js | 26 +- tests/commands/finalize.test.js | 68 - tests/commands/login.test.js | 7 - tests/commands/logout.test.js | 25 +- tests/commands/orgs.test.js | 9 +- tests/commands/preview-cli.test.js | 146 ++ tests/commands/preview.test.js | 989 +--------- tests/commands/run.test.js | 1555 +--------------- tests/commands/status.test.js | 669 ++----- tests/commands/tdd.test.js | 1098 +---------- tests/commands/upload-cli.test.js | 172 +- tests/commands/upload.test.js | 962 +--------- tests/commands/whoami.test.js | 22 +- tests/server-manager/index.test.js | 9 - tests/server/handlers/api-handler.test.js | 9 - tests/server/handlers/tdd-handler.test.js | 443 ----- tests/server/routers/events.test.js | 16 +- tests/services/config-service.test.js | 10 - tests/tdd/metadata/hotspot-metadata.test.js | 8 - tests/tdd/runtime-context.test.js | 13 - tests/tdd/tdd-service.test.js | 697 +------ tests/unit/ci-env.test.js | 391 ---- tests/utils/ci-env.test.js | 127 ++ 69 files changed, 934 insertions(+), 9781 deletions(-) delete mode 100644 src/config/index.js delete mode 100644 src/project/index.js delete mode 100644 src/tdd/index.js delete mode 100644 src/tdd/services/baseline-downloader.js delete mode 100644 src/tdd/services/hotspot-service.js delete mode 100644 src/utils/environment.js delete mode 100644 tests/unit/ci-env.test.js diff --git a/clients/ember/src/index.js b/clients/ember/src/index.js index 96871a80..29d2766c 100644 --- a/clients/ember/src/index.js +++ b/clients/ember/src/index.js @@ -20,10 +20,9 @@ * // tests/acceptance/my-test.js * import { vizzlyScreenshot } from '@vizzly-testing/ember/test-support'; * - * test('renders correctly', async function(assert) { + * test('renders correctly', async function() { * await visit('/'); * await vizzlyScreenshot('homepage'); - * assert.ok(true); * }); */ diff --git a/clients/ember/src/testem-config.js b/clients/ember/src/testem-config.js index 8174fb4b..206ac216 100644 --- a/clients/ember/src/testem-config.js +++ b/clients/ember/src/testem-config.js @@ -165,5 +165,3 @@ export function configure(userConfig = {}, playwrightOptions = {}) { return config; } - -export { browserMappings }; diff --git a/clients/ember/tests/unit/testem-config.test.js b/clients/ember/tests/unit/testem-config.test.js index ecd36d3f..06a7feec 100644 --- a/clients/ember/tests/unit/testem-config.test.js +++ b/clients/ember/tests/unit/testem-config.test.js @@ -1,14 +1,10 @@ import assert from 'node:assert'; import { existsSync, readFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; -import { afterEach, beforeEach, describe, it } from 'node:test'; -import { browserMappings, configure } from '../../src/testem-config.js'; +import { afterEach, describe, it } from 'node:test'; +import { configure } from '../../src/testem-config.js'; describe('testem-config', () => { - beforeEach(() => { - // Clean up before each test - }); - afterEach(() => { // Clean up any playwright.json that was written let playwrightConfig = join(process.cwd(), '.vizzly', 'playwright.json'); @@ -181,26 +177,4 @@ describe('testem-config', () => { assert.deepStrictEqual(input, inputCopy, 'input should not be mutated'); }); }); - - describe('browserMappings', () => { - it('maps Chrome to VizzlyChrome', () => { - assert.strictEqual(browserMappings.Chrome, 'VizzlyChrome'); - }); - - it('maps chrome (lowercase) to VizzlyChrome', () => { - assert.strictEqual(browserMappings.chrome, 'VizzlyChrome'); - }); - - it('maps Firefox to VizzlyFirefox', () => { - assert.strictEqual(browserMappings.Firefox, 'VizzlyFirefox'); - }); - - it('maps Safari to VizzlyWebKit', () => { - assert.strictEqual(browserMappings.Safari, 'VizzlyWebKit'); - }); - - it('maps WebKit to VizzlyWebKit', () => { - assert.strictEqual(browserMappings.WebKit, 'VizzlyWebKit'); - }); - }); }); diff --git a/clients/static-site/src/screenshot.js b/clients/static-site/src/screenshot.js index ffb3fd0d..a326ea3e 100644 --- a/clients/static-site/src/screenshot.js +++ b/clients/static-site/src/screenshot.js @@ -15,11 +15,6 @@ try { vizzlyScreenshot = async () => {}; } -/** @internal Replace vizzlyScreenshot for testing */ -export function _setVizzlyScreenshot(fn) { - vizzlyScreenshot = fn; -} - /** * Generate screenshot name from page path * Viewport info goes in properties for grouping @@ -116,14 +111,18 @@ export async function captureScreenshot(page, options = {}) { * @param {Object} pageObj - Page object * @param {Object} viewport - Viewport object * @param {Object} screenshotOptions - Screenshot options + * @param {Object} deps - External dependencies + * @param {Function} deps.vizzlyScreenshot - Screenshot transport * @returns {Promise} */ export async function captureAndSendScreenshot( page, pageObj, viewport, - screenshotOptions = {} + screenshotOptions = {}, + deps = {} ) { + let sendScreenshot = deps.vizzlyScreenshot || vizzlyScreenshot; let name = generateScreenshotName(pageObj); let properties = generateScreenshotProperties(viewport, { ...screenshotOptions, @@ -136,5 +135,5 @@ export async function captureAndSendScreenshot( vizzlyOptions.requestTimeout = screenshotOptions.requestTimeout; } - await vizzlyScreenshot(name, screenshot, vizzlyOptions); + await sendScreenshot(name, screenshot, vizzlyOptions); } diff --git a/clients/static-site/tests/concurrency.test.js b/clients/static-site/tests/concurrency.test.js index dc824059..c51b2e67 100644 --- a/clients/static-site/tests/concurrency.test.js +++ b/clients/static-site/tests/concurrency.test.js @@ -42,15 +42,6 @@ describe('mapWithConcurrency', () => { assert.ok(maxConcurrent <= 2); }); - it('handles async function results', async () => { - let items = [1, 2, 3]; - - await mapWithConcurrency(items, async item => item * 2, 2); - - // Should complete without error - assert.ok(true); - }); - it('handles errors in processing', async () => { let items = [1, 2, 3]; diff --git a/clients/static-site/tests/pool.test.js b/clients/static-site/tests/pool.test.js index 16d692fa..55a2950c 100644 --- a/clients/static-site/tests/pool.test.js +++ b/clients/static-site/tests/pool.test.js @@ -33,35 +33,20 @@ function createMockContext(id) { * Create a mock browser for testing (Playwright style) */ function createMockBrowser() { - let contextCount = 0; - let newContextCalls = 0; + let contexts = []; return { newContext: mock.fn(async () => { - contextCount++; - newContextCalls++; - return createMockContext(contextCount); + let context = createMockContext(contexts.length + 1); + contexts.push(context); + return context; }), - getPageCount: () => contextCount, - getNewPageCalls: () => newContextCalls, + getContextCount: () => contexts.length, + getContexts: () => contexts, }; } describe('createTabPool', () => { - it('creates a pool with acquire, release, drain, and stats', () => { - let browser = createMockBrowser(); - let pool = createTabPool(browser, 3); - - assert.ok(pool.acquire); - assert.ok(pool.release); - assert.ok(pool.drain); - assert.ok(pool.stats); - assert.strictEqual(typeof pool.acquire, 'function'); - assert.strictEqual(typeof pool.release, 'function'); - assert.strictEqual(typeof pool.drain, 'function'); - assert.strictEqual(typeof pool.stats, 'function'); - }); - it('reports correct initial stats', () => { let browser = createMockBrowser(); let pool = createTabPool(browser, 5); @@ -80,7 +65,7 @@ describe('createTabPool', () => { let tab = await pool.acquire(); - assert.strictEqual(browser.getNewPageCalls(), 1); + assert.strictEqual(browser.getContextCount(), 1); assert.strictEqual(tab.id, 1); assert.strictEqual(pool.stats().total, 1); }); @@ -93,7 +78,7 @@ describe('createTabPool', () => { let tab2 = await pool.acquire(); let tab3 = await pool.acquire(); - assert.strictEqual(browser.getNewPageCalls(), 3); + assert.strictEqual(browser.getContextCount(), 3); assert.strictEqual(tab1.id, 1); assert.strictEqual(tab2.id, 2); assert.strictEqual(tab3.id, 3); @@ -109,7 +94,7 @@ describe('createTabPool', () => { let tab2 = await pool.acquire(); - assert.strictEqual(browser.getNewPageCalls(), 1); + assert.strictEqual(browser.getContextCount(), 1); assert.strictEqual(tab2, tab1); }); @@ -221,9 +206,9 @@ describe('createTabPool', () => { await pool.drain(); - // Context close is called, not page close - assert.strictEqual(page1._poolEntry.context.close.mock.callCount(), 1); - assert.strictEqual(page2._poolEntry.context.close.mock.callCount(), 1); + let contexts = browser.getContexts(); + assert.strictEqual(contexts[0].close.mock.callCount(), 1); + assert.strictEqual(contexts[1].close.mock.callCount(), 1); assert.strictEqual(pool.stats().available, 0); assert.strictEqual(pool.stats().total, 0); }); @@ -252,7 +237,8 @@ describe('createTabPool', () => { let page2 = await pool.acquire(); // Make first context throw on close - page1._poolEntry.context.close = mock.fn(async () => { + let contexts = browser.getContexts(); + contexts[0].close = mock.fn(async () => { throw new Error('Close failed'); }); @@ -262,8 +248,8 @@ describe('createTabPool', () => { // Should not throw await pool.drain(); - assert.strictEqual(page1._poolEntry.context.close.mock.callCount(), 1); - assert.strictEqual(page2._poolEntry.context.close.mock.callCount(), 1); + assert.strictEqual(contexts[0].close.mock.callCount(), 1); + assert.strictEqual(contexts[1].close.mock.callCount(), 1); }); }); @@ -285,7 +271,7 @@ describe('createTabPool', () => { assert.strictEqual(results.length, 10); // Should have created at most 3 tabs - assert.ok(browser.getPageCount() <= 3); + assert.ok(browser.getContextCount() <= 3); }); }); @@ -297,7 +283,7 @@ describe('createTabPool', () => { // First tab created let tab1 = await pool.acquire(); let originalId = tab1.id; - assert.strictEqual(browser.getNewPageCalls(), 1); + assert.strictEqual(browser.getContextCount(), 1); // Use 1 await pool.release(tab1); @@ -315,7 +301,7 @@ describe('createTabPool', () => { // Now acquire should get a fresh tab (recycled) let tab4 = await pool.acquire(); assert.notStrictEqual(tab4.id, originalId); - assert.strictEqual(browser.getNewPageCalls(), 2); + assert.strictEqual(browser.getContextCount(), 2); }); it('tracks recycled count in stats', async () => { @@ -350,11 +336,12 @@ describe('createTabPool', () => { await pool.release(page); // use 1 page = await pool.acquire(); - assert.strictEqual(page._poolEntry.context.close.mock.callCount(), 0); + let [context] = browser.getContexts(); + assert.strictEqual(context.close.mock.callCount(), 0); await pool.release(page); // use 2 - triggers recycle - assert.strictEqual(page._poolEntry.context.close.mock.callCount(), 1); + assert.strictEqual(context.close.mock.callCount(), 1); }); it('hands off fresh tab to waiting acquirer during recycling', async () => { @@ -408,16 +395,14 @@ describe('createTabPool', () => { let pool = createTabPool(browser, 1, { recycleAfter: 2 }); let page = await pool.acquire(); - page._poolEntry.context.close = mock.fn(async () => { + let [context] = browser.getContexts(); + context.close = mock.fn(async () => { throw new Error('Close failed'); }); await pool.release(page); // use 1 page = await pool.acquire(); - page._poolEntry.context.close = mock.fn(async () => { - throw new Error('Close failed'); - }); // Should not throw despite close error await pool.release(page); // use 2 - triggers recycle @@ -425,53 +410,4 @@ describe('createTabPool', () => { assert.strictEqual(pool.stats().recycled, 1); }); }); - - describe('_poolEntry metadata', () => { - it('preserves _poolEntry reference on page', async () => { - let browser = createMockBrowser(); - let pool = createTabPool(browser, 2); - - let page = await pool.acquire(); - - assert.ok(page._poolEntry); - assert.strictEqual(page._poolEntry.page, page); - assert.strictEqual(page._poolEntry.useCount, 1); - }); - - it('increments useCount on each acquire', async () => { - let browser = createMockBrowser(); - let pool = createTabPool(browser, 1); - - let tab = await pool.acquire(); - assert.strictEqual(tab._poolEntry.useCount, 1); - - await pool.release(tab); - - tab = await pool.acquire(); - assert.strictEqual(tab._poolEntry.useCount, 2); - - await pool.release(tab); - - tab = await pool.acquire(); - assert.strictEqual(tab._poolEntry.useCount, 3); - }); - - it('increments useCount when handing off to waiter', async () => { - let browser = createMockBrowser(); - let pool = createTabPool(browser, 1); - - let tab = await pool.acquire(); - assert.strictEqual(tab._poolEntry.useCount, 1); - - // Someone is waiting - let acquirePromise = pool.acquire(); - - // Release hands off directly to waiter - pool.release(tab); - - let sameTab = await acquirePromise; - assert.strictEqual(sameTab, tab); - assert.strictEqual(tab._poolEntry.useCount, 2); - }); - }); }); diff --git a/clients/static-site/tests/screenshot.test.js b/clients/static-site/tests/screenshot.test.js index 8cdb14da..b6bc287f 100644 --- a/clients/static-site/tests/screenshot.test.js +++ b/clients/static-site/tests/screenshot.test.js @@ -6,7 +6,6 @@ import assert from 'node:assert'; import { describe, it, mock } from 'node:test'; import { buildScreenshotCheckObject } from '../../../src/api/core.js'; import { - _setVizzlyScreenshot, captureAndSendScreenshot, generateScreenshotName, generateScreenshotProperties, @@ -192,8 +191,6 @@ describe('captureAndSendScreenshot', () => { url: () => 'http://localhost:3000/docs', }; - _setVizzlyScreenshot(mockVizzlyScreenshot); - await captureAndSendScreenshot( page, { path: '/docs' }, @@ -203,7 +200,8 @@ describe('captureAndSendScreenshot', () => { fullPage: false, requestTimeout: 120000, properties: { page: 'docs', test: 'static-site' }, - } + }, + { vizzlyScreenshot: mockVizzlyScreenshot } ); assert.strictEqual(mockVizzlyScreenshot.mock.callCount(), 1); @@ -234,8 +232,6 @@ describe('captureAndSendScreenshot', () => { url: () => 'http://localhost:3000/docs', }; - _setVizzlyScreenshot(mockVizzlyScreenshot); - await captureAndSendScreenshot( page, { path: '/docs' }, @@ -244,7 +240,8 @@ describe('captureAndSendScreenshot', () => { browser: 'chromium', fullPage: true, timeout: 45_000, - } + }, + { vizzlyScreenshot: mockVizzlyScreenshot } ); let [, , options] = mockVizzlyScreenshot.mock.calls[0].arguments; diff --git a/clients/static-site/tests/tasks.test.js b/clients/static-site/tests/tasks.test.js index e67e2557..3eb6ef23 100644 --- a/clients/static-site/tests/tasks.test.js +++ b/clients/static-site/tests/tasks.test.js @@ -4,7 +4,7 @@ import assert from 'node:assert'; import { describe, it, mock } from 'node:test'; -import { generateTasks, processAllTasks, processTask } from '../src/tasks.js'; +import { generateTasks, processAllTasks } from '../src/tasks.js'; describe('generateTasks', () => { it('generates tasks for each page × viewport combination', () => { @@ -132,76 +132,6 @@ describe('generateTasks', () => { }); }); -describe('processTask', () => { - it('sets viewport, navigates, and captures screenshot', async () => { - let setViewportCalls = []; - let navigateCalls = []; - let screenshotCalls = []; - - let deps = { - setViewport: async (tab, viewport) => { - setViewportCalls.push({ tab, viewport }); - }, - navigateToUrl: async (tab, url) => { - navigateCalls.push({ tab, url }); - }, - captureAndSendScreenshot: async (tab, page, viewport, opts) => { - screenshotCalls.push({ tab, page, viewport, opts }); - }, - }; - - let tab = { id: 1 }; - let task = { - page: { path: '/test' }, - viewport: { name: 'desktop', width: 1920, height: 1080 }, - hook: null, - url: 'http://localhost:3000/test', - screenshotOptions: { fullPage: true, requestTimeout: 120000 }, - }; - - await processTask(tab, task, deps); - - assert.strictEqual(setViewportCalls.length, 1); - assert.strictEqual(setViewportCalls[0].tab, tab); - assert.deepStrictEqual(setViewportCalls[0].viewport, task.viewport); - - assert.strictEqual(navigateCalls.length, 1); - assert.strictEqual(navigateCalls[0].url, task.url); - - assert.strictEqual(screenshotCalls.length, 1); - assert.deepStrictEqual(screenshotCalls[0].opts, { - fullPage: true, - requestTimeout: 120000, - }); - }); - - it('runs hook if provided', async () => { - let hookCalls = []; - - let deps = { - setViewport: async () => {}, - navigateToUrl: async () => {}, - captureAndSendScreenshot: async () => {}, - }; - - let tab = { id: 1 }; - let task = { - page: { path: '/test' }, - viewport: { name: 'desktop', width: 1920, height: 1080 }, - hook: async t => { - hookCalls.push(t); - }, - url: 'http://localhost:3000/test', - screenshotOptions: {}, - }; - - await processTask(tab, task, deps); - - assert.strictEqual(hookCalls.length, 1); - assert.strictEqual(hookCalls[0], tab); - }); -}); - describe('processAllTasks', () => { it('processes all tasks and returns errors', async () => { let acquireCount = 0; diff --git a/clients/storybook/src/navigation.js b/clients/storybook/src/navigation.js index 4987968f..9c94249e 100644 --- a/clients/storybook/src/navigation.js +++ b/clients/storybook/src/navigation.js @@ -168,14 +168,3 @@ async function clientSideNavigation(page, storyId, timeout) { { id: storyId, timeoutMs: timeout } ); } - -/** - * Reset tab's Storybook state (called on tab recycle) - * @param {Object} entry - Pool entry for the tab - */ -export function resetStorybookState(entry) { - if (entry) { - entry.storybookInitialized = false; - entry.currentStoryId = null; - } -} diff --git a/clients/storybook/src/screenshot.js b/clients/storybook/src/screenshot.js index 33119027..360e366f 100644 --- a/clients/storybook/src/screenshot.js +++ b/clients/storybook/src/screenshot.js @@ -15,11 +15,6 @@ try { vizzlyScreenshot = async () => {}; } -/** @internal Replace vizzlyScreenshot for testing */ -export function _setVizzlyScreenshot(fn) { - vizzlyScreenshot = fn; -} - /** * Generate screenshot name from story and viewport * Format: "ComponentName-StoryName@viewportName" @@ -111,14 +106,18 @@ export async function captureScreenshot(page, options = {}) { * @param {Object} story - Story object * @param {Object} viewport - Viewport object * @param {Object} screenshotOptions - Screenshot options + * @param {Object} deps - External dependencies + * @param {Function} deps.vizzlyScreenshot - Screenshot transport * @returns {Promise} */ export async function captureAndSendScreenshot( page, story, viewport, - screenshotOptions = {} + screenshotOptions = {}, + deps = {} ) { + let sendScreenshot = deps.vizzlyScreenshot || vizzlyScreenshot; let name = generateScreenshotName(story, viewport); let verbose = process.env.VIZZLY_LOG_LEVEL === 'debug'; @@ -140,7 +139,7 @@ export async function captureAndSendScreenshot( vizzlyOptions.requestTimeout = screenshotOptions.requestTimeout; } - await vizzlyScreenshot(name, screenshot, vizzlyOptions); + await sendScreenshot(name, screenshot, vizzlyOptions); let sendTime = Date.now() - t1; if (verbose) { diff --git a/clients/storybook/tests/concurrency.test.js b/clients/storybook/tests/concurrency.test.js index 21e4e320..f413ac23 100644 --- a/clients/storybook/tests/concurrency.test.js +++ b/clients/storybook/tests/concurrency.test.js @@ -46,15 +46,6 @@ describe('mapWithConcurrency', () => { ); }); - it('should handle async function results', async () => { - let items = [1, 2, 3]; - - await mapWithConcurrency(items, async item => item * 2, 2); - - // Should complete without error - assert.ok(true); - }); - it('should handle errors in processing', async () => { let items = [1, 2, 3]; diff --git a/clients/storybook/tests/navigation.test.js b/clients/storybook/tests/navigation.test.js index 28175a6e..ef766ae3 100644 --- a/clients/storybook/tests/navigation.test.js +++ b/clients/storybook/tests/navigation.test.js @@ -4,11 +4,7 @@ import assert from 'node:assert'; import { describe, it, mock } from 'node:test'; -import { - generateStoryUrl, - navigateToStory, - resetStorybookState, -} from '../src/navigation.js'; +import { generateStoryUrl, navigateToStory } from '../src/navigation.js'; describe('generateStoryUrl', () => { it('generates correct iframe URL', () => { @@ -43,8 +39,6 @@ describe('navigateToStory', () => { assert.strictEqual(gotoCalls.length, 1); assert.ok(gotoCalls[0].includes('button--primary')); - assert.strictEqual(tab._poolEntry.storybookInitialized, true); - assert.strictEqual(tab._poolEntry.currentStoryId, 'button--primary'); }); it('uses client-side navigation on subsequent visits', async () => { @@ -67,7 +61,6 @@ describe('navigateToStory', () => { assert.strictEqual(gotoCalls.length, 0); // No full page navigation // Single evaluate call that handles navigation + waiting assert.strictEqual(tab.evaluate.mock.callCount(), 1); - assert.strictEqual(tab._poolEntry.currentStoryId, 'button--secondary'); }); it('skips navigation if same story', async () => { @@ -106,7 +99,6 @@ describe('navigateToStory', () => { assert.strictEqual(gotoCalls.length, 1); assert.ok(gotoCalls[0].includes('button--secondary')); - assert.strictEqual(tab._poolEntry.currentStoryId, 'button--secondary'); }); it('falls back to full navigation when Storybook never renders the story', async () => { @@ -151,7 +143,6 @@ describe('navigateToStory', () => { assert.strictEqual(gotoCalls.length, 1); assert.ok(gotoCalls[0].includes('button--secondary')); assert.strictEqual(listeners.has('storyRendered'), false); - assert.strictEqual(tab._poolEntry.currentStoryId, 'button--secondary'); }); it('falls back to domcontentloaded on timeout', async () => { @@ -200,23 +191,3 @@ describe('navigateToStory', () => { assert.strictEqual(tab.goto.mock.callCount(), 1); }); }); - -describe('resetStorybookState', () => { - it('clears storybookInitialized flag', () => { - let entry = { - storybookInitialized: true, - currentStoryId: 'button--primary', - }; - - resetStorybookState(entry); - - assert.strictEqual(entry.storybookInitialized, false); - assert.strictEqual(entry.currentStoryId, null); - }); - - it('handles null entry gracefully', () => { - // Should not throw - resetStorybookState(null); - resetStorybookState(undefined); - }); -}); diff --git a/clients/storybook/tests/pool.test.js b/clients/storybook/tests/pool.test.js index 16d692fa..55a2950c 100644 --- a/clients/storybook/tests/pool.test.js +++ b/clients/storybook/tests/pool.test.js @@ -33,35 +33,20 @@ function createMockContext(id) { * Create a mock browser for testing (Playwright style) */ function createMockBrowser() { - let contextCount = 0; - let newContextCalls = 0; + let contexts = []; return { newContext: mock.fn(async () => { - contextCount++; - newContextCalls++; - return createMockContext(contextCount); + let context = createMockContext(contexts.length + 1); + contexts.push(context); + return context; }), - getPageCount: () => contextCount, - getNewPageCalls: () => newContextCalls, + getContextCount: () => contexts.length, + getContexts: () => contexts, }; } describe('createTabPool', () => { - it('creates a pool with acquire, release, drain, and stats', () => { - let browser = createMockBrowser(); - let pool = createTabPool(browser, 3); - - assert.ok(pool.acquire); - assert.ok(pool.release); - assert.ok(pool.drain); - assert.ok(pool.stats); - assert.strictEqual(typeof pool.acquire, 'function'); - assert.strictEqual(typeof pool.release, 'function'); - assert.strictEqual(typeof pool.drain, 'function'); - assert.strictEqual(typeof pool.stats, 'function'); - }); - it('reports correct initial stats', () => { let browser = createMockBrowser(); let pool = createTabPool(browser, 5); @@ -80,7 +65,7 @@ describe('createTabPool', () => { let tab = await pool.acquire(); - assert.strictEqual(browser.getNewPageCalls(), 1); + assert.strictEqual(browser.getContextCount(), 1); assert.strictEqual(tab.id, 1); assert.strictEqual(pool.stats().total, 1); }); @@ -93,7 +78,7 @@ describe('createTabPool', () => { let tab2 = await pool.acquire(); let tab3 = await pool.acquire(); - assert.strictEqual(browser.getNewPageCalls(), 3); + assert.strictEqual(browser.getContextCount(), 3); assert.strictEqual(tab1.id, 1); assert.strictEqual(tab2.id, 2); assert.strictEqual(tab3.id, 3); @@ -109,7 +94,7 @@ describe('createTabPool', () => { let tab2 = await pool.acquire(); - assert.strictEqual(browser.getNewPageCalls(), 1); + assert.strictEqual(browser.getContextCount(), 1); assert.strictEqual(tab2, tab1); }); @@ -221,9 +206,9 @@ describe('createTabPool', () => { await pool.drain(); - // Context close is called, not page close - assert.strictEqual(page1._poolEntry.context.close.mock.callCount(), 1); - assert.strictEqual(page2._poolEntry.context.close.mock.callCount(), 1); + let contexts = browser.getContexts(); + assert.strictEqual(contexts[0].close.mock.callCount(), 1); + assert.strictEqual(contexts[1].close.mock.callCount(), 1); assert.strictEqual(pool.stats().available, 0); assert.strictEqual(pool.stats().total, 0); }); @@ -252,7 +237,8 @@ describe('createTabPool', () => { let page2 = await pool.acquire(); // Make first context throw on close - page1._poolEntry.context.close = mock.fn(async () => { + let contexts = browser.getContexts(); + contexts[0].close = mock.fn(async () => { throw new Error('Close failed'); }); @@ -262,8 +248,8 @@ describe('createTabPool', () => { // Should not throw await pool.drain(); - assert.strictEqual(page1._poolEntry.context.close.mock.callCount(), 1); - assert.strictEqual(page2._poolEntry.context.close.mock.callCount(), 1); + assert.strictEqual(contexts[0].close.mock.callCount(), 1); + assert.strictEqual(contexts[1].close.mock.callCount(), 1); }); }); @@ -285,7 +271,7 @@ describe('createTabPool', () => { assert.strictEqual(results.length, 10); // Should have created at most 3 tabs - assert.ok(browser.getPageCount() <= 3); + assert.ok(browser.getContextCount() <= 3); }); }); @@ -297,7 +283,7 @@ describe('createTabPool', () => { // First tab created let tab1 = await pool.acquire(); let originalId = tab1.id; - assert.strictEqual(browser.getNewPageCalls(), 1); + assert.strictEqual(browser.getContextCount(), 1); // Use 1 await pool.release(tab1); @@ -315,7 +301,7 @@ describe('createTabPool', () => { // Now acquire should get a fresh tab (recycled) let tab4 = await pool.acquire(); assert.notStrictEqual(tab4.id, originalId); - assert.strictEqual(browser.getNewPageCalls(), 2); + assert.strictEqual(browser.getContextCount(), 2); }); it('tracks recycled count in stats', async () => { @@ -350,11 +336,12 @@ describe('createTabPool', () => { await pool.release(page); // use 1 page = await pool.acquire(); - assert.strictEqual(page._poolEntry.context.close.mock.callCount(), 0); + let [context] = browser.getContexts(); + assert.strictEqual(context.close.mock.callCount(), 0); await pool.release(page); // use 2 - triggers recycle - assert.strictEqual(page._poolEntry.context.close.mock.callCount(), 1); + assert.strictEqual(context.close.mock.callCount(), 1); }); it('hands off fresh tab to waiting acquirer during recycling', async () => { @@ -408,16 +395,14 @@ describe('createTabPool', () => { let pool = createTabPool(browser, 1, { recycleAfter: 2 }); let page = await pool.acquire(); - page._poolEntry.context.close = mock.fn(async () => { + let [context] = browser.getContexts(); + context.close = mock.fn(async () => { throw new Error('Close failed'); }); await pool.release(page); // use 1 page = await pool.acquire(); - page._poolEntry.context.close = mock.fn(async () => { - throw new Error('Close failed'); - }); // Should not throw despite close error await pool.release(page); // use 2 - triggers recycle @@ -425,53 +410,4 @@ describe('createTabPool', () => { assert.strictEqual(pool.stats().recycled, 1); }); }); - - describe('_poolEntry metadata', () => { - it('preserves _poolEntry reference on page', async () => { - let browser = createMockBrowser(); - let pool = createTabPool(browser, 2); - - let page = await pool.acquire(); - - assert.ok(page._poolEntry); - assert.strictEqual(page._poolEntry.page, page); - assert.strictEqual(page._poolEntry.useCount, 1); - }); - - it('increments useCount on each acquire', async () => { - let browser = createMockBrowser(); - let pool = createTabPool(browser, 1); - - let tab = await pool.acquire(); - assert.strictEqual(tab._poolEntry.useCount, 1); - - await pool.release(tab); - - tab = await pool.acquire(); - assert.strictEqual(tab._poolEntry.useCount, 2); - - await pool.release(tab); - - tab = await pool.acquire(); - assert.strictEqual(tab._poolEntry.useCount, 3); - }); - - it('increments useCount when handing off to waiter', async () => { - let browser = createMockBrowser(); - let pool = createTabPool(browser, 1); - - let tab = await pool.acquire(); - assert.strictEqual(tab._poolEntry.useCount, 1); - - // Someone is waiting - let acquirePromise = pool.acquire(); - - // Release hands off directly to waiter - pool.release(tab); - - let sameTab = await acquirePromise; - assert.strictEqual(sameTab, tab); - assert.strictEqual(tab._poolEntry.useCount, 2); - }); - }); }); diff --git a/clients/storybook/tests/screenshot.test.js b/clients/storybook/tests/screenshot.test.js index 752734d9..71aaabec 100644 --- a/clients/storybook/tests/screenshot.test.js +++ b/clients/storybook/tests/screenshot.test.js @@ -5,7 +5,6 @@ import assert from 'node:assert/strict'; import { describe, it, mock } from 'node:test'; import { - _setVizzlyScreenshot, captureAndSendScreenshot, captureScreenshot, generateScreenshotName, @@ -152,7 +151,6 @@ describe('captureScreenshot', () => { describe('captureAndSendScreenshot', () => { it('should send story and viewport metadata for isolated story preview', async () => { let mockVizzly = mock.fn(async () => {}); - _setVizzlyScreenshot(mockVizzly); let mockBuffer = Buffer.from('fake-screenshot'); let iframeUrl = @@ -164,7 +162,13 @@ describe('captureAndSendScreenshot', () => { let story = { id: 'button--primary', title: 'Button', name: 'Primary' }; let viewport = { name: 'desktop', width: 1920, height: 1080 }; - await captureAndSendScreenshot(mockPage, story, viewport); + await captureAndSendScreenshot( + mockPage, + story, + viewport, + {}, + { vizzlyScreenshot: mockVizzly } + ); assert.equal(mockVizzly.mock.calls.length, 1); let [name, , options] = mockVizzly.mock.calls[0].arguments; @@ -182,7 +186,6 @@ describe('captureAndSendScreenshot', () => { it('should pass screenshot options through', async () => { let mockVizzly = mock.fn(async () => {}); - _setVizzlyScreenshot(mockVizzly); let mockBuffer = Buffer.from('fake-screenshot'); let mockScreenshot = mock.fn(() => mockBuffer); @@ -194,9 +197,13 @@ describe('captureAndSendScreenshot', () => { let story = { id: 'card--default', title: 'Card', name: 'Default' }; let viewport = { name: 'mobile' }; - await captureAndSendScreenshot(mockPage, story, viewport, { - fullPage: false, - }); + await captureAndSendScreenshot( + mockPage, + story, + viewport, + { fullPage: false }, + { vizzlyScreenshot: mockVizzly } + ); assert.deepEqual(mockScreenshot.mock.calls[0].arguments[0], { fullPage: false, @@ -207,7 +214,6 @@ describe('captureAndSendScreenshot', () => { it('passes request timeout to the Vizzly client transport', async () => { let mockVizzly = mock.fn(async () => {}); - _setVizzlyScreenshot(mockVizzly); let mockBuffer = Buffer.from('fake-screenshot'); let mockScreenshot = mock.fn(() => mockBuffer); @@ -219,10 +225,16 @@ describe('captureAndSendScreenshot', () => { let story = { id: 'card--default', title: 'Card', name: 'Default' }; let viewport = { name: 'desktop', width: 1920, height: 1080 }; - await captureAndSendScreenshot(mockPage, story, viewport, { - timeout: 30_000, - requestTimeout: 60_000, - }); + await captureAndSendScreenshot( + mockPage, + story, + viewport, + { + timeout: 30_000, + requestTimeout: 60_000, + }, + { vizzlyScreenshot: mockVizzly } + ); assert.deepEqual(mockScreenshot.mock.calls[0].arguments[0], { fullPage: true, diff --git a/clients/storybook/tests/sdk-integration.test.js b/clients/storybook/tests/sdk-integration.test.js index 6414199d..cde0d1ef 100644 --- a/clients/storybook/tests/sdk-integration.test.js +++ b/clients/storybook/tests/sdk-integration.test.js @@ -283,9 +283,6 @@ describe('Storybook E2E with example-storybook', { skip: !runE2E }, () => { threshold: 0, properties: { test: 'e2e' }, }); - - // If we get here without error, screenshot succeeded - assert.ok(true, 'Screenshot should succeed'); } finally { await page.close(); } @@ -310,8 +307,6 @@ describe('Storybook E2E with example-storybook', { skip: !runE2E }, () => { threshold: 5, properties: { test: 'threshold' }, }); - - assert.ok(true, 'Screenshot with threshold should succeed'); } finally { await page.close(); } diff --git a/clients/storybook/tests/tasks.test.js b/clients/storybook/tests/tasks.test.js index c05457ed..e90eb98e 100644 --- a/clients/storybook/tests/tasks.test.js +++ b/clients/storybook/tests/tasks.test.js @@ -4,7 +4,7 @@ import assert from 'node:assert'; import { describe, it, mock } from 'node:test'; -import { generateTasks, processAllTasks, processTask } from '../src/tasks.js'; +import { generateTasks, processAllTasks } from '../src/tasks.js'; describe('generateTasks', () => { it('generates tasks for each story × viewport combination', () => { @@ -173,76 +173,6 @@ describe('generateTasks', () => { }); }); -describe('processTask', () => { - it('sets viewport, navigates, and captures screenshot', async () => { - let setViewportCalls = []; - let navigateCalls = []; - let screenshotCalls = []; - - let deps = { - setViewport: async (tab, viewport) => { - setViewportCalls.push({ tab, viewport }); - }, - navigateToStory: async (tab, storyId, baseUrl) => { - navigateCalls.push({ tab, storyId, baseUrl }); - }, - captureAndSendScreenshot: async (tab, story, viewport, opts) => { - screenshotCalls.push({ tab, story, viewport, opts }); - }, - }; - - let tab = { id: 1 }; - let task = { - story: { id: 'button--primary', title: 'Button', name: 'Primary' }, - viewport: { name: 'desktop', width: 1920, height: 1080 }, - hook: null, - storyId: 'button--primary', - baseUrl: 'http://localhost:6006', - screenshotOptions: { fullPage: true }, - }; - - await processTask(tab, task, deps); - - assert.strictEqual(setViewportCalls.length, 1); - assert.strictEqual(setViewportCalls[0].tab, tab); - assert.deepStrictEqual(setViewportCalls[0].viewport, task.viewport); - - assert.strictEqual(navigateCalls.length, 1); - assert.strictEqual(navigateCalls[0].storyId, 'button--primary'); - assert.strictEqual(navigateCalls[0].baseUrl, 'http://localhost:6006'); - - assert.strictEqual(screenshotCalls.length, 1); - assert.deepStrictEqual(screenshotCalls[0].opts, { fullPage: true }); - }); - - it('runs hook if provided', async () => { - let hookCalls = []; - - let deps = { - setViewport: async () => {}, - navigateToStory: async () => {}, - captureAndSendScreenshot: async () => {}, - }; - - let tab = { id: 1 }; - let task = { - story: { id: 'button--primary', title: 'Button', name: 'Primary' }, - viewport: { name: 'desktop', width: 1920, height: 1080 }, - hook: async t => { - hookCalls.push(t); - }, - storyId: 'button--primary', - baseUrl: 'http://localhost:6006', - screenshotOptions: {}, - }; - - await processTask(tab, task, deps); - - assert.strictEqual(hookCalls.length, 1); - assert.strictEqual(hookCalls[0], tab); - }); -}); - describe('processAllTasks', () => { it('processes all tasks and returns errors', async () => { let acquireCount = 0; diff --git a/src/cli.js b/src/cli.js index a8a263a9..269197d9 100644 --- a/src/cli.js +++ b/src/cli.js @@ -3,16 +3,13 @@ import 'dotenv/config'; import { existsSync, statSync } from 'node:fs'; import { Option, program } from 'commander'; import { apiCommand, validateApiOptions } from './commands/api.js'; -import { - baselinesCommand, - validateBaselinesOptions, -} from './commands/baselines.js'; +import { baselinesCommand } from './commands/baselines.js'; import { buildsCommand, validateBuildsOptions } from './commands/builds.js'; import { comparisonsCommand, validateComparisonsOptions, } from './commands/comparisons.js'; -import { configCommand, validateConfigOptions } from './commands/config-cmd.js'; +import { configCommand } from './commands/config-cmd.js'; import { contextBuildCommand, contextComparisonCommand, @@ -25,15 +22,15 @@ import { validateContextScreenshotOptions, validateContextSimilarOptions, } from './commands/context.js'; -import { doctorCommand, validateDoctorOptions } from './commands/doctor.js'; +import { doctorCommand } from './commands/doctor.js'; import { finalizeCommand, validateFinalizeOptions, } from './commands/finalize.js'; import { init } from './commands/init.js'; -import { loginCommand, validateLoginOptions } from './commands/login.js'; -import { logoutCommand, validateLogoutOptions } from './commands/logout.js'; -import { orgsCommand, validateOrgsOptions } from './commands/orgs.js'; +import { loginCommand } from './commands/login.js'; +import { logoutCommand } from './commands/logout.js'; +import { orgsCommand } from './commands/orgs.js'; import { previewCommand, validatePreviewOptions } from './commands/preview.js'; import { projectLinkCommand, @@ -63,7 +60,7 @@ import { validateTddStartOptions, } from './commands/tdd-daemon.js'; import { uploadCommand, validateUploadOptions } from './commands/upload.js'; -import { validateWhoamiOptions, whoamiCommand } from './commands/whoami.js'; +import { whoamiCommand } from './commands/whoami.js'; import { createPluginServices } from './plugin-api.js'; import { loadPlugins } from './plugin-loader.js'; import { createServices } from './services/index.js'; @@ -1159,12 +1156,6 @@ program .action(async (key, options) => { let globalOptions = getGlobalOptions(); - // Validate options - const validationErrors = validateConfigOptions(options); - if (validationErrors.length > 0) { - reportValidationErrors(validationErrors); - } - await configCommand(key, options, globalOptions); }); @@ -1188,12 +1179,6 @@ Note: Baselines are stored locally in .vizzly/baselines/ during TDD mode. .action(async options => { let globalOptions = getGlobalOptions(); - // Validate options - const validationErrors = validateBaselinesOptions(options); - if (validationErrors.length > 0) { - reportValidationErrors(validationErrors); - } - await baselinesCommand(options, globalOptions); }); @@ -1355,11 +1340,6 @@ or the single organization for a project token. .action(async options => { let globalOptions = getGlobalOptions(); - const validationErrors = validateOrgsOptions(options); - if (validationErrors.length > 0) { - reportValidationErrors(validationErrors); - } - await orgsCommand(options, globalOptions); }); @@ -1501,12 +1481,6 @@ program .action(async options => { let globalOptions = getGlobalOptions(); - // Validate options - const validationErrors = validateDoctorOptions(options); - if (validationErrors.length > 0) { - reportValidationErrors(validationErrors); - } - await doctorCommand(options, globalOptions); }); @@ -1517,12 +1491,6 @@ program .action(async options => { let globalOptions = getGlobalOptions(); - // Validate options - const validationErrors = validateLoginOptions(options); - if (validationErrors.length > 0) { - reportValidationErrors(validationErrors); - } - await loginCommand(options, globalOptions); }); @@ -1533,12 +1501,6 @@ program .action(async options => { let globalOptions = getGlobalOptions(); - // Validate options - const validationErrors = validateLogoutOptions(options); - if (validationErrors.length > 0) { - reportValidationErrors(validationErrors); - } - await logoutCommand(options, globalOptions); }); @@ -1549,12 +1511,6 @@ program .action(async options => { let globalOptions = getGlobalOptions(); - // Validate options - const validationErrors = validateWhoamiOptions(options); - if (validationErrors.length > 0) { - reportValidationErrors(validationErrors); - } - await whoamiCommand(options, globalOptions); }); diff --git a/src/commands/baselines.js b/src/commands/baselines.js index 2e4e9ab1..f08f310f 100644 --- a/src/commands/baselines.js +++ b/src/commands/baselines.js @@ -349,10 +349,3 @@ export function formatBytes(bytes) { if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } - -/** - * Validate baselines command options - */ -export function validateBaselinesOptions() { - return []; -} diff --git a/src/commands/config-cmd.js b/src/commands/config-cmd.js index 56c52a8e..dce1fc69 100644 --- a/src/commands/config-cmd.js +++ b/src/commands/config-cmd.js @@ -180,10 +180,3 @@ function getNestedValue(obj, path) { return current; } - -/** - * Validate config command options - */ -export function validateConfigOptions() { - return []; -} diff --git a/src/commands/doctor.js b/src/commands/doctor.js index 08018d56..d82901b4 100644 --- a/src/commands/doctor.js +++ b/src/commands/doctor.js @@ -280,11 +280,3 @@ export async function doctorCommand( if (hasErrors) exit(1); } } - -/** - * Validate doctor options (no specific validation needed) - * @param {Object} options - Command options - */ -export function validateDoctorOptions() { - return []; // No specific validation for now -} diff --git a/src/commands/init.js b/src/commands/init.js index f3df3ae1..772dfd0e 100644 --- a/src/commands/init.js +++ b/src/commands/init.js @@ -54,7 +54,7 @@ export function getInitConfigPath(cwd = process.cwd()) { return path.join(cwd, 'vizzly.config.js'); } -export function getPackagedAgentSkillPath(baseDir = commandDir) { +function getPackagedAgentSkillPath(baseDir = commandDir) { return path.resolve(baseDir, '..', '..', 'skills', 'vizzly'); } @@ -75,11 +75,11 @@ export async function fileExists(filePath, access = fs.access) { } } -export function getPluginsWithConfig(plugins = []) { +function getPluginsWithConfig(plugins = []) { return plugins.filter(plugin => plugin.configSchema); } -export function getPluginConfigNames(plugins = []) { +function getPluginConfigNames(plugins = []) { return getPluginsWithConfig(plugins).map(plugin => plugin.name); } @@ -164,7 +164,7 @@ export function formatPluginConfig(plugin, output = null) { } } -export function generatePluginConfigs(plugins = [], output = null) { +function generatePluginConfigs(plugins = [], output = null) { let sections = []; for (let plugin of plugins) { @@ -282,7 +282,7 @@ async function shouldInstallAgentSkill(options, deps) { let agentGuidanceStart = ''; let agentGuidanceEnd = ''; -export function createAgentGuidanceContent() { +function createAgentGuidanceContent() { return `${agentGuidanceStart} ## Visual Testing With Vizzly @@ -305,7 +305,7 @@ function appendSection(content, section) { return `${content.replace(/\s*$/, '')}\n\n${section}`; } -export async function upsertProjectAgentGuidance({ +async function upsertProjectAgentGuidance({ cwd, access = fs.access, readFile = fs.readFile, @@ -456,17 +456,12 @@ async function loadInitPlugins(options, deps) { try { let config = await deps.loadConfig(options.config, {}); - return await deps.loadPlugins(options.config, config, null); + return await deps.loadPlugins(options.config, config); } catch { return []; } } -// Export factory function for CLI -export function createInitCommand(options) { - return () => init(options); -} - export async function init(options = {}, deps = {}) { let resolvedDeps = createInitDeps(deps); diff --git a/src/commands/login.js b/src/commands/login.js index 7f7b47d1..c352b5be 100644 --- a/src/commands/login.js +++ b/src/commands/login.js @@ -292,16 +292,3 @@ export async function loginCommand( } } } - -/** - * Validate login options - * @param {Object} options - Command options - */ -export function validateLoginOptions() { - let errors = []; - - // No specific validation needed for login command - // OAuth device flow handles everything via browser - - return errors; -} diff --git a/src/commands/logout.js b/src/commands/logout.js index ea452fc0..18536ee4 100644 --- a/src/commands/logout.js +++ b/src/commands/logout.js @@ -83,15 +83,3 @@ export async function logoutCommand( exit(1); } } - -/** - * Validate logout options - * @param {Object} options - Command options - */ -export function validateLogoutOptions() { - let errors = []; - - // No specific validation needed for logout command - - return errors; -} diff --git a/src/commands/orgs.js b/src/commands/orgs.js index 0503baa3..8f4c085c 100644 --- a/src/commands/orgs.js +++ b/src/commands/orgs.js @@ -106,12 +106,3 @@ export async function orgsCommand( exit(1); } } - -/** - * Validate orgs options - * @param {Object} _options - Command options - * @returns {string[]} Validation errors - */ -export function validateOrgsOptions(_options = {}) { - return []; -} diff --git a/src/commands/whoami.js b/src/commands/whoami.js index 3347d3ba..9af35f11 100644 --- a/src/commands/whoami.js +++ b/src/commands/whoami.js @@ -211,15 +211,3 @@ export async function whoamiCommand( } } } - -/** - * Validate whoami options - * @param {Object} options - Command options - */ -export function validateWhoamiOptions() { - let errors = []; - - // No specific validation needed for whoami command - - return errors; -} diff --git a/src/config/index.js b/src/config/index.js deleted file mode 100644 index d8f9ce0e..00000000 --- a/src/config/index.js +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Config Module - Public exports - * - * Provides functional configuration primitives: - * - core.js: Pure functions for merging, serialization, validation results - * - operations.js: Config operations with dependency injection - */ - -// Core pure functions -export { - buildGlobalConfigResult, - buildMergedConfig, - buildMergedConfigResult, - buildProjectConfigResult, - buildValidationFailure, - buildValidationSuccess, - CONFIG_DEFAULTS, - deepMerge, - extractCosmiconfigResult, - extractEnvOverrides, - getConfigFormat, - READ_SCOPES, - serializeConfig, - serializeToJavaScript, - serializeToJson, - stringifyWithIndent, - validateReadScope, - validateWriteScope, - WRITE_SCOPES, -} from './core.js'; - -// Config operations (take dependencies as parameters) -export { - getConfig, - getConfigSource, - getGlobalConfig, - getMergedConfig, - getProjectConfig, - updateConfig, - updateGlobalConfig, - updateProjectConfig, - validateConfig, - writeProjectConfigFile, -} from './operations.js'; diff --git a/src/plugin-loader.js b/src/plugin-loader.js index f42fa3f2..94ca4bac 100644 --- a/src/plugin-loader.js +++ b/src/plugin-loader.js @@ -67,7 +67,7 @@ export async function loadPlugins(configPath, config) { * Discover installed plugins from node_modules/@vizzly-testing/* * @returns {Promise} Array of plugin info objects */ -export async function discoverInstalledPlugins() { +async function discoverInstalledPlugins() { let plugins = []; try { diff --git a/src/project/index.js b/src/project/index.js deleted file mode 100644 index 98341dcb..00000000 --- a/src/project/index.js +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Project Module - Public exports - * - * Provides functional project management primitives: - * - core.js: Pure functions for validation, URL building, response extraction - * - operations.js: Project operations with dependency injection - */ - -// Core pure functions -export { - buildBuildsQueryParams, - buildBuildsUrl, - buildMappingResult, - buildNoApiServiceError, - buildNoAuthError, - buildOrgHeader, - buildProjectFetchError, - buildProjectUrl, - buildTokenCreateError, - buildTokenRevokeError, - buildTokensFetchError, - buildTokensUrl, - enrichProjectsWithOrg, - extractBuilds, - extractOrganizations, - extractProject, - extractProjects, - extractToken, - extractTokens, - mappingsToArray, - validateDirectory, - validateProjectData, -} from './core.js'; - -// Project operations (take dependencies as parameters) -export { - createMapping, - createProjectToken, - getMapping, - getProject, - getProjectWithApiToken, - getProjectWithOAuth, - getRecentBuilds, - getRecentBuildsWithApiToken, - getRecentBuildsWithOAuth, - listMappings, - listProjects, - listProjectsWithApiToken, - listProjectsWithOAuth, - listProjectTokens, - removeMapping, - revokeProjectToken, - switchProject, -} from './operations.js'; diff --git a/src/reporter/src/hooks/queries/use-auth-queries.js b/src/reporter/src/hooks/queries/use-auth-queries.js index 651d867b..64350997 100644 --- a/src/reporter/src/hooks/queries/use-auth-queries.js +++ b/src/reporter/src/hooks/queries/use-auth-queries.js @@ -28,14 +28,3 @@ export function usePollAuthorization() { }, }); } - -export function useLogout() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: auth.logout, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.auth }); - queryClient.invalidateQueries({ queryKey: queryKeys.cloud }); - }, - }); -} diff --git a/src/reporter/src/hooks/queries/use-config-queries.js b/src/reporter/src/hooks/queries/use-config-queries.js index 94a19c02..6f51e025 100644 --- a/src/reporter/src/hooks/queries/use-config-queries.js +++ b/src/reporter/src/hooks/queries/use-config-queries.js @@ -10,22 +10,6 @@ export function useConfig(options = {}) { }); } -export function useProjectConfig(options = {}) { - return useQuery({ - queryKey: queryKeys.projectConfig(), - queryFn: config.getProject, - ...options, - }); -} - -export function useGlobalConfig(options = {}) { - return useQuery({ - queryKey: queryKeys.globalConfig(), - queryFn: config.getGlobal, - ...options, - }); -} - export function useUpdateProjectConfig() { const queryClient = useQueryClient(); return useMutation({ @@ -35,19 +19,3 @@ export function useUpdateProjectConfig() { }, }); } - -export function useUpdateGlobalConfig() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: config.updateGlobal, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.config }); - }, - }); -} - -export function useValidateConfig() { - return useMutation({ - mutationFn: config.validate, - }); -} diff --git a/src/reporter/src/utils/comparison-helpers.js b/src/reporter/src/utils/comparison-helpers.js index de270eeb..98f40ff0 100644 --- a/src/reporter/src/utils/comparison-helpers.js +++ b/src/reporter/src/utils/comparison-helpers.js @@ -1,80 +1,5 @@ -import { - CheckCircleIcon, - ExclamationTriangleIcon, - XCircleIcon, -} from '@heroicons/react/24/outline'; import { COMPARISON_STATUS } from './constants.js'; -const STATUS_CONFIG = { - [COMPARISON_STATUS.NEW]: { - type: 'success', - label: 'New Baseline', - description: 'First screenshot - baseline created', - icon: CheckCircleIcon, - colorClass: 'green', - }, - [COMPARISON_STATUS.BASELINE_CREATED]: { - type: 'success', - label: 'Baseline Created', - description: 'This is the first screenshot for this test', - icon: CheckCircleIcon, - colorClass: 'green', - }, - [COMPARISON_STATUS.PASSED]: { - type: 'success', - label: 'Passed', - description: 'Screenshot matches the baseline', - icon: CheckCircleIcon, - colorClass: 'green', - }, - [COMPARISON_STATUS.FAILED]: { - type: 'error', - label: 'Visual Differences Detected', - icon: XCircleIcon, - colorClass: 'red', - }, - [COMPARISON_STATUS.ERROR]: { - type: 'warning', - label: 'Comparison Error', - description: 'An error occurred during comparison', - icon: ExclamationTriangleIcon, - colorClass: 'yellow', - }, - [COMPARISON_STATUS.REJECTED]: { - type: 'warning', - label: 'Changes Rejected', - description: 'Visual changes were rejected - baseline kept', - icon: XCircleIcon, - colorClass: 'yellow', - }, -}; - -export function getStatusInfo(comparison) { - let config = STATUS_CONFIG[comparison.status] || { - type: 'warning', - label: 'Unknown Status', - description: 'Unable to determine comparison status', - icon: ExclamationTriangleIcon, - colorClass: 'yellow', - }; - - // Add diff percentage to failed comparisons - if (comparison.status === COMPARISON_STATUS.FAILED) { - const diffPercent = comparison.diffPercentage?.toFixed(2) || '0.00'; - config = { - ...config, - description: `${diffPercent}% difference from baseline`, - }; - } - - return config; -} - -export function calculatePassRate(summary) { - if (!summary || summary.total === 0) return 0; - return Math.round((summary.passed / summary.total) * 100); -} - export function sortComparisons(comparisons, sortBy) { return [...comparisons].sort((a, b) => { if (sortBy === 'priority') { diff --git a/src/reporter/src/utils/constants.js b/src/reporter/src/utils/constants.js index 3db0e66c..ceb3dfe5 100644 --- a/src/reporter/src/utils/constants.js +++ b/src/reporter/src/utils/constants.js @@ -18,12 +18,6 @@ export const SORT_TYPES = { TIME: 'time', }; -export const CONNECTION_STATUS = { - CONNECTING: 'connecting', - CONNECTED: 'connected', - DISCONNECTED: 'disconnected', -}; - export const COMPARISON_STATUS = { PASSED: 'passed', FAILED: 'failed', @@ -32,9 +26,3 @@ export const COMPARISON_STATUS = { ERROR: 'error', REJECTED: 'rejected', }; - -export const USER_ACTION = { - ACCEPTING: 'accepting', - ACCEPTED: 'accepted', - REJECTED: 'rejected', -}; diff --git a/src/reporter/src/utils/image-url.js b/src/reporter/src/utils/image-url.js index defb3faf..f226dd0d 100644 --- a/src/reporter/src/utils/image-url.js +++ b/src/reporter/src/utils/image-url.js @@ -4,7 +4,7 @@ * * This is a no-op for non-local URLs. */ -export let LOCAL_IMAGE_PREFIX = '/images/'; +let LOCAL_IMAGE_PREFIX = '/images/'; export function withImageVersion(url, version) { if (!url || typeof url !== 'string') { diff --git a/src/server/routers/events.js b/src/server/routers/events.js index e15ce07e..e02f1be2 100644 --- a/src/server/routers/events.js +++ b/src/server/routers/events.js @@ -36,7 +36,7 @@ export function readJsonFile(path) { /** * Read report data and attach baseline metadata for the stats view. */ -export function readReportDataFile({ reportDataPath, baselineMetadataPath }) { +function readReportDataFile({ reportDataPath, baselineMetadataPath }) { let data = readJsonFile(reportDataPath); if (!data) { return null; @@ -83,7 +83,7 @@ function summaryChanged(oldData, newData) { /** * Build incremental SSE events by diffing old vs new report data. */ -export function buildReportDataEvents(oldData, newData) { +function buildReportDataEvents(oldData, newData) { if (!oldData) { return [{ type: 'reportData', data: newData }]; } @@ -119,7 +119,7 @@ export function buildReportDataEvents(oldData, newData) { /** * Watch report-data.json with fs.watch plus a lightweight mtime fallback. */ -export function watchReportDataFile({ +function watchReportDataFile({ workingDir, reportDataPath, onReportDataChanged, diff --git a/src/tdd/index.js b/src/tdd/index.js deleted file mode 100644 index a13a45c5..00000000 --- a/src/tdd/index.js +++ /dev/null @@ -1,71 +0,0 @@ -/** - * TDD Module Exports - * - * Re-exports all TDD functionality for clean imports. - */ - -export { - calculateHotspotCoverage, - shouldFilterAsHotspot, -} from './core/hotspot-coverage.js'; -// Core pure functions -export { - generateBaselineFilename, - generateComparisonId, - generateScreenshotSignature, -} from './core/signature.js'; - -// Metadata I/O -export { - createEmptyBaselineMetadata, - findScreenshotBySignature, - loadBaselineMetadata, - saveBaselineMetadata, - upsertScreenshotInMetadata, -} from './metadata/baseline-metadata.js'; - -export { - createHotspotCache, - getHotspotForScreenshot, - loadHotspotMetadata, - saveHotspotMetadata, -} from './metadata/hotspot-metadata.js'; -export { - baselineMatchesSha, - buildBaselineMetadataEntry, - downloadBaselineImage, - downloadBaselinesInBatches, -} from './services/baseline-downloader.js'; -// Services -export { - baselineExists, - clearBaselineData, - getBaselinePath, - getCurrentPath, - getDiffPath, - initializeDirectories, - promoteCurrentToBaseline, - readBaseline, - readCurrent, - saveBaseline, - saveCurrent, -} from './services/baseline-manager.js'; -export { - buildErrorComparison, - buildFailedComparison, - buildNewComparison, - buildPassedComparison, - compareImages, - isDimensionMismatchError, -} from './services/comparison-service.js'; -export { downloadHotspots } from './services/hotspot-service.js'; -export { - buildResults, - calculateSummary, - findComparison, - findComparisonById, - getErrorComparisons, - getFailedComparisons, - getNewComparisons, - isSuccessful, -} from './services/result-service.js'; diff --git a/src/tdd/services/baseline-downloader.js b/src/tdd/services/baseline-downloader.js deleted file mode 100644 index 4b98ee85..00000000 --- a/src/tdd/services/baseline-downloader.js +++ /dev/null @@ -1,163 +0,0 @@ -/** - * Baseline Downloader - * - * Functions for downloading baseline images from the cloud API. - * These are lower-level utilities - orchestration happens in TddService. - */ - -import { existsSync, writeFileSync } from 'node:fs'; -import { fetchWithTimeout } from '../../utils/fetch-utils.js'; - -/** - * Download a single baseline image - * - * @param {string} url - URL to download from - * @param {string} destPath - Destination file path - * @param {Object} options - Options - * @param {number} options.timeout - Request timeout in ms (default: 30000) - * @returns {Promise<{ success: boolean, error?: string }>} - */ -export async function downloadBaselineImage(url, destPath, options = {}) { - let { timeout = 30000 } = options; - - try { - let response = await fetchWithTimeout(url, { timeout }); - - if (!response.ok) { - return { - success: false, - error: `HTTP ${response.status}: ${response.statusText}`, - }; - } - - let buffer = Buffer.from(await response.arrayBuffer()); - writeFileSync(destPath, buffer); - - return { success: true }; - } catch (error) { - return { - success: false, - error: error.message, - }; - } -} - -/** - * Check if a baseline already exists with matching SHA - * - * @param {string} filePath - Path to the baseline file - * @param {string} expectedSha - Expected SHA256 hash - * @param {Map} shaMap - Map of filename -> sha256 - * @returns {boolean} - */ -export function baselineMatchesSha(filePath, expectedSha, shaMap) { - if (!existsSync(filePath) || !expectedSha) { - return false; - } - - let filename = filePath.split('/').pop(); - let storedSha = shaMap.get(filename); - - return storedSha === expectedSha; -} - -/** - * Download multiple baselines in batches - * - * @param {Array} screenshots - Screenshots to download - * @param {Object} options - Options - * @param {string} options.baselinePath - Path to baselines directory - * @param {Map} options.existingShaMap - Existing SHA map for skip logic - * @param {number} options.batchSize - Concurrent downloads (default: 5) - * @param {Function} options.onProgress - Progress callback (downloaded, skipped, errors, total) - * @param {Function} options.getFilePath - Function to get file path for a screenshot - * @returns {Promise<{ downloaded: number, skipped: number, errors: number }>} - */ -export async function downloadBaselinesInBatches(screenshots, options = {}) { - let { - existingShaMap = new Map(), - batchSize = 5, - onProgress, - getFilePath, - } = options; - - let downloaded = 0; - let skipped = 0; - let errors = 0; - let total = screenshots.length; - - // Process in batches - for (let i = 0; i < screenshots.length; i += batchSize) { - let batch = screenshots.slice(i, i + batchSize); - - let batchPromises = batch.map(async screenshot => { - let filePath = getFilePath(screenshot); - let url = screenshot.original_url; - - if (!url) { - errors++; - return; - } - - // Skip if SHA matches - if (baselineMatchesSha(filePath, screenshot.sha256, existingShaMap)) { - skipped++; - downloaded++; - return; - } - - let result = await downloadBaselineImage(url, filePath); - - if (result.success) { - downloaded++; - } else { - errors++; - } - }); - - await Promise.all(batchPromises); - - if (onProgress) { - onProgress(downloaded, skipped, errors, total); - } - } - - return { downloaded, skipped, errors }; -} - -/** - * Build baseline metadata entry for a downloaded screenshot - * - * @param {Object} screenshot - Screenshot data from API - * @param {string} filename - Local filename - * @param {string} filePath - Full file path - * @param {Object} buildInfo - Build information - * @returns {Object} Metadata entry - */ -export function buildBaselineMetadataEntry( - screenshot, - filename, - filePath, - buildInfo = {} -) { - return { - name: screenshot.name, - originalName: screenshot.name, - sha256: screenshot.sha256, - id: screenshot.id, - filename, - path: filePath, - browser: screenshot.browser || screenshot.metadata?.browser, - viewport_width: - screenshot.viewport_width || - screenshot.metadata?.viewport?.width || - screenshot.properties?.viewport?.width, - originalUrl: screenshot.original_url, - fileSize: screenshot.file_size, - dimensions: screenshot.dimensions, - // Build info for tracking - buildId: buildInfo.buildId, - commitSha: buildInfo.commitSha, - approvalStatus: screenshot.approval_status, - }; -} diff --git a/src/tdd/services/hotspot-service.js b/src/tdd/services/hotspot-service.js deleted file mode 100644 index a1a0fe67..00000000 --- a/src/tdd/services/hotspot-service.js +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Hotspot Service - * - * Functions for downloading and managing hotspot data from the cloud. - * Hotspots identify regions that frequently change due to dynamic content. - */ - -import { saveHotspotMetadata } from '../metadata/hotspot-metadata.js'; - -/** - * Download hotspots for screenshots from cloud API - * - * @param {Object} options - * @param {Object} options.api - ApiService instance - * @param {string} options.workingDir - Working directory - * @param {string[]} options.screenshotNames - Names of screenshots to get hotspots for - * @returns {Promise<{ success: boolean, count: number, regionCount: number, error?: string }>} - */ -export async function downloadHotspots(options) { - let { api, workingDir, screenshotNames } = options; - - if (!screenshotNames || screenshotNames.length === 0) { - return { success: true, count: 0, regionCount: 0 }; - } - - try { - let response = await api.getHotspots(screenshotNames); - - if (!response?.hotspots) { - return { success: false, error: 'API returned no hotspot data' }; - } - - // Save hotspots to disk - saveHotspotMetadata(workingDir, response.hotspots, response.summary); - - // Calculate stats - let count = Object.keys(response.hotspots).length; - let regionCount = Object.values(response.hotspots).reduce( - (sum, h) => sum + (h.regions?.length || 0), - 0 - ); - - return { success: true, count, regionCount }; - } catch (error) { - return { success: false, error: error.message }; - } -} diff --git a/src/utils/environment.js b/src/utils/environment.js deleted file mode 100644 index c2eb3de1..00000000 --- a/src/utils/environment.js +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Environment detection utilities for handling CI/interactive environments - */ - -/** - * Check if running in a CI environment - * Based on common CI environment variables - * @returns {boolean} True if running in CI - */ -export function isCI() { - return Boolean( - process.env.CI || // Generic CI flag - process.env.CONTINUOUS_INTEGRATION || // TravisCI, CircleCI - process.env.BUILD_NUMBER || // Jenkins - process.env.GITHUB_ACTIONS || // GitHub Actions - process.env.GITLAB_CI || // GitLab CI - process.env.CIRCLECI || // CircleCI - process.env.TRAVIS || // TravisCI - process.env.APPVEYOR || // AppVeyor - process.env.CODEBUILD_BUILD_ID || // AWS CodeBuild - process.env.TEAMCITY_VERSION || // TeamCity - process.env.TF_BUILD || // Azure DevOps - process.env.DRONE || // Drone CI - process.env.BITBUCKET_BUILD_NUMBER // Bitbucket Pipelines - ); -} - -/** - * Check if stdout supports interactive features - * @returns {boolean} True if interactive features are supported - */ -export function isInteractiveTerminal() { - return Boolean( - process.stdout.isTTY && - process.stdin.isTTY && - (!process.env.TERM || process.env.TERM !== 'dumb') - ); -} - -/** - * Determine if interactive mode should be enabled - * Takes into account CI detection, TTY support, and explicit flags - * @param {Object} options - * @param {boolean} [options.noInteractive] - Explicit flag to disable interactive mode - * @param {boolean} [options.interactive] - Explicit flag to force interactive mode - * @returns {boolean} True if interactive mode should be enabled - */ -export function shouldUseInteractiveMode(options = {}) { - // Explicit flags take priority - // Commander.js sets interactive=false when --no-interactive is used - if (options.interactive === false) { - return false; - } - - if (options.interactive === true) { - return true; - } - - // Legacy support for explicit noInteractive flag - if (options.noInteractive === true) { - return false; - } - - // Auto-detect based on environment - return !isCI() && isInteractiveTerminal(); -} - -/** - * Get environment type for logging/debugging - * @returns {string} Environment type description - */ -export function getEnvironmentType() { - if (isCI()) { - return 'CI'; - } - - if (!isInteractiveTerminal()) { - return 'non-interactive'; - } - - return 'interactive'; -} - -/** - * Get detailed environment info for debugging - * @returns {Object} Environment details - */ -export function getEnvironmentDetails() { - return { - isCI: isCI(), - isInteractiveTerminal: isInteractiveTerminal(), - environmentType: getEnvironmentType(), - stdoutIsTTY: Boolean(process.stdout.isTTY), - stdinIsTTY: Boolean(process.stdin.isTTY), - ciVars: { - CI: process.env.CI, - CONTINUOUS_INTEGRATION: process.env.CONTINUOUS_INTEGRATION, - GITHUB_ACTIONS: process.env.GITHUB_ACTIONS, - GITLAB_CI: process.env.GITLAB_CI, - CIRCLECI: process.env.CIRCLECI, - TRAVIS: process.env.TRAVIS, - }, - termType: process.env.TERM, - }; -} diff --git a/tests/api/client.test.js b/tests/api/client.test.js index da86c793..9e7f9866 100644 --- a/tests/api/client.test.js +++ b/tests/api/client.test.js @@ -14,18 +14,6 @@ describe('api/client', () => { }); describe('createApiClient', () => { - it('creates client with token', () => { - let client = createApiClient({ - token: 'test-token', - baseUrl: 'https://api.test', - }); - - assert.ok(client.request); - assert.ok(client.getBaseUrl); - assert.ok(client.getToken); - assert.ok(client.getUserAgent); - }); - it('creates client with allowNoToken option', () => { let client = createApiClient({ allowNoToken: true, diff --git a/tests/auth/client.test.js b/tests/auth/client.test.js index 027e12dc..83112cdb 100644 --- a/tests/auth/client.test.js +++ b/tests/auth/client.test.js @@ -28,15 +28,6 @@ describe('auth/client', () => { }); describe('createAuthClient', () => { - it('creates client with default user agent', () => { - let client = createAuthClient({ baseUrl: 'https://api.test' }); - - assert.ok(client.request); - assert.ok(client.authenticatedRequest); - assert.ok(client.getBaseUrl); - assert.ok(client.getUserAgent); - }); - it('returns configured base URL', () => { let client = createAuthClient({ baseUrl: 'https://custom.api' }); diff --git a/tests/cli/tdd-lifecycle.test.js b/tests/cli/tdd-lifecycle.test.js index 1e961398..a8837872 100644 --- a/tests/cli/tdd-lifecycle.test.js +++ b/tests/cli/tdd-lifecycle.test.js @@ -97,6 +97,22 @@ function screenshotCommand(name) { } describe('cli/tdd lifecycle', () => { + it('rejects baseline downloads without an API token', async () => { + let result = await runCLI( + [ + 'tdd', + 'run', + 'node -e "process.exit(0)"', + '--baseline-build', + 'build-123', + ], + { cwd: createWorkspace() } + ); + + assert.strictEqual(result.code, 1); + assert.match(`${result.stdout}\n${result.stderr}`, /API token required/); + }); + it('keeps JSON TDD run output parseable on stdout', async () => { let cwd = createWorkspace(); let port = await getFreePort(); @@ -201,6 +217,44 @@ describe('cli/tdd lifecycle', () => { assert.strictEqual(reportData.comparisons[0].status, 'new'); }); + it('reports a passing screenshot on a subsequent run', async () => { + let cwd = createWorkspace(); + let firstPort = await getFreePort(); + let secondPort = await getFreePort(); + let args = port => [ + '--no-color', + 'tdd', + 'run', + screenshotCommand('cli-lifecycle-passing-homepage'), + '--port', + String(port), + '--no-open', + ]; + + let first = await runCLI(args(firstPort), { cwd }); + assert.strictEqual( + first.code, + 0, + `stdout:\n${first.stdout}\nstderr:\n${first.stderr}` + ); + assert.match(first.stdout, /"status":"new"/); + + let second = await runCLI(args(secondPort), { cwd }); + assert.strictEqual( + second.code, + 0, + `stdout:\n${second.stdout}\nstderr:\n${second.stderr}` + ); + assert.match(second.stdout, /1 passed/); + assert.doesNotMatch(second.stdout, /Review changes:/); + + let reportData = JSON.parse( + readFileSync(join(cwd, '.vizzly', 'report-data.json'), 'utf8') + ); + assert.strictEqual(reportData.summary.passed, 1); + assert.strictEqual(reportData.comparisons[0].status, 'passed'); + }); + it('starts, reports, lists, and stops a daemon on an explicit port', async () => { let cwd = createWorkspace(); let port = await getFreePort(); diff --git a/tests/commands/baselines.test.js b/tests/commands/baselines.test.js index 4a7ea242..8a53f8f8 100644 --- a/tests/commands/baselines.test.js +++ b/tests/commands/baselines.test.js @@ -10,7 +10,6 @@ import { formatBytes, getFilename, getViewport, - validateBaselinesOptions, } from '../../src/commands/baselines.js'; function createMockOutput() { @@ -87,12 +86,6 @@ function writeBaselineFixture(workspace) { } describe('commands/baselines', () => { - describe('validateBaselinesOptions', () => { - it('returns no errors', () => { - assert.deepStrictEqual(validateBaselinesOptions({}), []); - }); - }); - describe('baseline helpers', () => { it('extracts filenames and viewport data from metadata variants', () => { assert.strictEqual( diff --git a/tests/commands/builds.test.js b/tests/commands/builds.test.js index 8666acd3..70e76ef9 100644 --- a/tests/commands/builds.test.js +++ b/tests/commands/builds.test.js @@ -172,74 +172,6 @@ describe('commands/builds', () => { assert.strictEqual(dataCall.args[0].id, 'build-1'); }); - it('passes include as a string to getBuild when --comparisons is set', async () => { - let output = createMockOutput(); - let capturedInclude = null; - - await buildsCommand( - { build: 'build-1', comparisons: true }, - { json: true }, - { - loadConfig: async () => ({ apiKey: 'test-token' }), - createApiClient: () => ({}), - getBuild: async (_client, _buildId, include) => { - capturedInclude = include; - return { build: { id: 'build-1', status: 'completed' } }; - }, - output, - exit: () => {}, - } - ); - - assert.strictEqual(capturedInclude, 'comparisons'); - }); - - it('passes undefined include to getBuild when --comparisons is not set', async () => { - let output = createMockOutput(); - let capturedInclude = 'NOT_CALLED'; - - await buildsCommand( - { build: 'build-1' }, - { json: true }, - { - loadConfig: async () => ({ apiKey: 'test-token' }), - createApiClient: () => ({}), - getBuild: async (_client, _buildId, include) => { - capturedInclude = include; - return { build: { id: 'build-1', status: 'completed' } }; - }, - output, - exit: () => {}, - } - ); - - assert.strictEqual(capturedInclude, undefined); - }); - - it('passes filters to API', async () => { - let output = createMockOutput(); - let capturedFilters = null; - - await buildsCommand( - { branch: 'main', status: 'completed', limit: 10 }, - { json: true }, - { - loadConfig: async () => ({ apiKey: 'test-token' }), - createApiClient: () => ({}), - getBuilds: async (_client, filters) => { - capturedFilters = filters; - return { builds: [], pagination: { total: 0, hasMore: false } }; - }, - output, - exit: () => {}, - } - ); - - assert.strictEqual(capturedFilters.branch, 'main'); - assert.strictEqual(capturedFilters.status, 'completed'); - assert.strictEqual(capturedFilters.limit, 10); - }); - it('includes URLs and honeydiff in comparisonDetails', async () => { let output = createMockOutput(); let mockBuild = { @@ -313,50 +245,5 @@ describe('commands/builds', () => { assert.strictEqual(comp.honeydiff, null); assert.strictEqual(comp.urls.diff, null); }); - - it('passes project filter to API', async () => { - let output = createMockOutput(); - let capturedFilters = null; - - await buildsCommand( - { project: 'proj-123' }, - { json: true }, - { - loadConfig: async () => ({ apiKey: 'test-token' }), - createApiClient: () => ({}), - getBuilds: async (_client, filters) => { - capturedFilters = filters; - return { builds: [], pagination: { total: 0, hasMore: false } }; - }, - output, - exit: () => {}, - } - ); - - assert.strictEqual(capturedFilters.project, 'proj-123'); - }); - - it('passes organization filter to API', async () => { - let output = createMockOutput(); - let capturedFilters = null; - - await buildsCommand( - { project: 'storybook', org: 'my-org' }, - { json: true }, - { - loadConfig: async () => ({ apiKey: 'test-token' }), - createApiClient: () => ({}), - getBuilds: async (_client, filters) => { - capturedFilters = filters; - return { builds: [], pagination: { total: 0, hasMore: false } }; - }, - output, - exit: () => {}, - } - ); - - assert.strictEqual(capturedFilters.project, 'storybook'); - assert.strictEqual(capturedFilters.organization, 'my-org'); - }); }); }); diff --git a/tests/commands/comparisons.test.js b/tests/commands/comparisons.test.js index d65a79b0..ee1cf60f 100644 --- a/tests/commands/comparisons.test.js +++ b/tests/commands/comparisons.test.js @@ -239,31 +239,8 @@ describe('commands/comparisons', () => { }); }); - it('passes include as a string to getBuild, not an object', async () => { - let output = createMockOutput(); - let capturedInclude = null; - - await comparisonsCommand( - { build: 'build-1' }, - { json: true }, - { - loadConfig: async () => ({ apiKey: 'test-token' }), - createApiClient: () => ({}), - getBuild: async (_client, _buildId, include) => { - capturedInclude = include; - return { build: { id: 'build-1', comparisons: [] } }; - }, - output, - exit: () => {}, - } - ); - - assert.strictEqual(capturedInclude, 'comparisons'); - }); - it('searches comparisons by name', async () => { let output = createMockOutput(); - let capturedName = null; let mockComparisons = [ { id: 'comp-1', @@ -279,19 +256,15 @@ describe('commands/comparisons', () => { { loadConfig: async () => ({ apiKey: 'test-token' }), createApiClient: () => ({}), - searchComparisons: async (_client, name) => { - capturedName = name; - return { - comparisons: mockComparisons, - pagination: { total: 1, hasMore: false }, - }; - }, + searchComparisons: async () => ({ + comparisons: mockComparisons, + pagination: { total: 1, hasMore: false }, + }), output, exit: () => {}, } ); - assert.strictEqual(capturedName, 'button-*'); let dataCall = output.calls.find(c => c.method === 'data'); assert.ok(dataCall); assert.strictEqual(dataCall.args[0].comparisons.length, 1); @@ -392,57 +365,6 @@ describe('commands/comparisons', () => { ); }); - it('passes project filter to search', async () => { - let output = createMockOutput(); - let capturedFilters = null; - - await comparisonsCommand( - { name: 'button-*', project: 'my-project' }, - { json: true }, - { - loadConfig: async () => ({ apiKey: 'test-token' }), - createApiClient: () => ({}), - searchComparisons: async (_client, _name, filters) => { - capturedFilters = filters; - return { - comparisons: [], - pagination: { total: 0, hasMore: false }, - }; - }, - output, - exit: () => {}, - } - ); - - assert.strictEqual(capturedFilters.project, 'my-project'); - }); - - it('passes organization filter to search', async () => { - let output = createMockOutput(); - let capturedFilters = null; - - await comparisonsCommand( - { name: 'button-*', project: 'storybook', org: 'my-org' }, - { json: true }, - { - loadConfig: async () => ({ apiKey: 'test-token' }), - createApiClient: () => ({}), - searchComparisons: async (_client, _name, filters) => { - capturedFilters = filters; - return { - comparisons: [], - pagination: { total: 0, hasMore: false }, - }; - }, - output, - exit: () => {}, - } - ); - - assert.strictEqual(capturedFilters.project, 'storybook'); - assert.strictEqual(capturedFilters.organization, 'my-org'); - }); - it('fetches single comparison by ID', async () => { let output = createMockOutput(); let mockComparison = { diff --git a/tests/commands/config-cmd.test.js b/tests/commands/config-cmd.test.js index ed69af2b..5720b986 100644 --- a/tests/commands/config-cmd.test.js +++ b/tests/commands/config-cmd.test.js @@ -1,9 +1,6 @@ import assert from 'node:assert'; import { describe, it } from 'node:test'; -import { - configCommand, - validateConfigOptions, -} from '../../src/commands/config-cmd.js'; +import { configCommand } from '../../src/commands/config-cmd.js'; /** * Create mock output object that tracks calls @@ -26,13 +23,6 @@ function createMockOutput() { } describe('commands/config', () => { - describe('validateConfigOptions', () => { - it('returns no errors', () => { - let errors = validateConfigOptions({}); - assert.deepStrictEqual(errors, []); - }); - }); - describe('configCommand', () => { it('outputs full config as JSON', async () => { let output = createMockOutput(); diff --git a/tests/commands/context.test.js b/tests/commands/context.test.js index c362dc4b..5964ebf4 100644 --- a/tests/commands/context.test.js +++ b/tests/commands/context.test.js @@ -1,11 +1,6 @@ import assert from 'node:assert'; import { describe, it } from 'node:test'; import { - contextBuildCommand, - contextComparisonCommand, - contextReviewQueueCommand, - contextScreenshotCommand, - contextSimilarCommand, validateContextBuildOptions, validateContextComparisonOptions, validateContextReviewQueueOptions, @@ -13,1640 +8,60 @@ import { validateContextSimilarOptions, } from '../../src/commands/context.js'; -function createMockOutput() { - let calls = []; - return { - calls, - configure: opts => calls.push({ method: 'configure', args: [opts] }), - error: (msg, err) => calls.push({ method: 'error', args: [msg, err] }), - startSpinner: msg => calls.push({ method: 'startSpinner', args: [msg] }), - stopSpinner: () => calls.push({ method: 'stopSpinner', args: [] }), - header: (cmd, mode) => calls.push({ method: 'header', args: [cmd, mode] }), - print: msg => calls.push({ method: 'print', args: [msg] }), - blank: () => calls.push({ method: 'blank', args: [] }), - hint: msg => calls.push({ method: 'hint', args: [msg] }), - labelValue: (label, value) => - calls.push({ method: 'labelValue', args: [label, value] }), - cleanup: () => calls.push({ method: 'cleanup', args: [] }), - data: obj => calls.push({ method: 'data', args: [obj] }), - getColors: () => ({ - bold: s => s, - dim: s => s, - brand: { - success: s => s, - warning: s => s, - error: s => s, - info: s => s, - }, - }), - }; -} - describe('commands/context', () => { - describe('validation', () => { - it('rejects invalid source values', () => { - let errors = validateContextBuildOptions({ source: 'moon' }); - assert.ok(errors.includes('--source must be one of: auto, cloud, local')); - }); - - it('rejects invalid compact agent include values', () => { - let errors = validateContextBuildOptions({ include: 'screenshots,logs' }); - assert.ok( - errors.includes( - '--include must contain only: screenshots, diffs, comments' - ) - ); - }); - - it('rejects invalid compact evidence offsets', () => { - assert.ok( - validateContextBuildOptions({ offset: -1 }).includes( - '--offset must be a non-negative integer' - ) - ); - assert.ok( - validateContextBuildOptions({ offset: 1.5 }).includes( - '--offset must be a non-negative integer' - ) - ); - }); - - it('rejects out-of-range comparison context limits', () => { - let errors = validateContextComparisonOptions({ similarLimit: 51 }); - assert.ok( - errors.includes('--similar-limit must be an integer between 1 and 50') - ); - }); - - it('rejects malformed and decimal comparison context limits', () => { - assert.ok( - validateContextComparisonOptions({ - similarLimit: Number('10abc'), - }).includes('--similar-limit must be an integer between 1 and 50') - ); - assert.ok( - validateContextComparisonOptions({ recentLimit: 4.5 }).includes( - '--recent-limit must be an integer between 1 and 50' - ) - ); - assert.ok( - validateContextComparisonOptions({ windowSize: 2.5 }).includes( - '--window-size must be an integer between 1 and 50' - ) - ); - }); - - it('requires project when org is provided for screenshot context', () => { - let errors = validateContextScreenshotOptions({ org: 'acme' }); - assert.ok(errors.includes('--org requires --project')); - }); - - it('rejects out-of-range similar limit', () => { - let errors = validateContextSimilarOptions({ limit: 0 }); - assert.ok(errors.includes('--limit must be an integer between 1 and 50')); - }); - - it('rejects negative review queue offsets', () => { - let errors = validateContextReviewQueueOptions({ offset: -1 }); - assert.ok(errors.includes('--offset must be a non-negative integer')); - }); - - it('rejects malformed and decimal review queue pagination', () => { - assert.ok( - validateContextReviewQueueOptions({ limit: Number('20abc') }).includes( - '--limit must be an integer between 1 and 100' - ) - ); - assert.ok( - validateContextReviewQueueOptions({ offset: 1.5 }).includes( - '--offset must be a non-negative integer' - ) - ); - }); - }); - - describe('contextBuildCommand', () => { - it('requires authentication', async () => { - let output = createMockOutput(); - let exitCode = null; - - await contextBuildCommand( - 'build-1', - {}, - {}, - { - loadConfig: async () => ({}), - output, - exit: code => { - exitCode = code; - }, - } - ); - - assert.strictEqual(exitCode, 1); - assert.ok(output.calls.some(call => call.method === 'error')); - }); - - it('returns build context in JSON mode', async () => { - let output = createMockOutput(); - let capturedQuery = 'not-called'; - let context = { - resource: 'build_context', - build: { id: 'build-1' }, - summary: { comparisons: { changed: 1 } }, - }; - - await contextBuildCommand( - 'build-1', - {}, - { json: true }, - { - loadConfig: async () => ({ - apiKey: 'token', - apiUrl: 'https://api.test', - }), - createApiClient: () => ({}), - getBuildContext: async (_client, _buildId, query) => { - capturedQuery = query; - return context; - }, - output, - exit: () => {}, - } - ); - - let dataCall = output.calls.find(call => call.method === 'data'); - assert.ok(dataCall); - assert.strictEqual(dataCall.args[0].resource, 'build_context'); - assert.strictEqual(dataCall.args[0].build.id, 'build-1'); - assert.strictEqual(dataCall.args[0].source, 'cloud'); - assert.deepStrictEqual(dataCall.args[0], { - ...context, - source: 'cloud', - }); - assert.strictEqual(capturedQuery, undefined); - }); - - it('returns compact agent JSON by default instead of the full context payload', async () => { - let output = createMockOutput(); - - await contextBuildCommand( - 'build-1', - { agent: true }, - { json: true }, - { - loadConfig: async () => ({ - apiKey: 'token', - apiUrl: 'https://api.test', - }), - createApiClient: () => ({}), - getBuildContext: async () => ({ - resource: 'build_context', - source: 'cloud', - scope: { - organization: { slug: 'acme', name: 'Acme' }, - project: { slug: 'web', name: 'Web', visibility: 'private' }, - }, - build: { - id: 'build-1', - name: 'main-abc123', - status: 'completed', - approval_status: 'pending', - }, - baseline: { - selected: { - id: 'baseline-1', - name: 'Approved Main', - approval_status: 'approved', - }, - selection_reason: 'latest approved build', - }, - status: { - needs_review: true, - reasons: ['comparisons_need_review'], - pending_comparisons: 2, - unresolved_comments: 0, - }, - summary: { - comparisons: { total: 2, changed: 1, new: 1, identical: 0 }, - }, - dynamic_regions: { - policies: [ - { - region: { id: 'region-1', status: 'confirmed' }, - assignments: [{ screenshotName: 'Dashboard' }], - }, - ], - proposals: { proposals: [] }, - }, - screenshots: [{ id: 'ss-1', name: 'Dashboard' }], - comparisons: [ - { - id: 'cmp-1', - screenshot_name: 'Dashboard', - result: 'changed', - visual_review: { state: 'pending', reviewer: null }, - needs_review: true, - diff: { - percentage: 1.23, - changed_pixels: 123, - total_pixels: 10000, - threshold: 2, - image_url: 'https://cdn.test/diff.png', - regions: [{ pixelCount: 50 }], - fingerprint_hash: 'fp-dashboard', - projection: { - clusters: { count: 1 }, - distribution: 'header', - }, - }, - screenshot: { - id: 'ss-1', - browser: 'chrome', - device: 'desktop', - viewport: { width: 1440, height: 900 }, - bitmap: { width: 2880, height: 1800 }, - metadata: { locale: 'en-US' }, - signature: 'Dashboard|1440|chrome', - original_url: 'https://cdn.test/current.png', - }, - baseline: { - id: 'base-ss-1', - build_id: 'baseline-1', - browser: 'chrome', - viewport: { width: 1440, height: 900 }, - bitmap: { width: 2880, height: 1800 }, - original_url: 'https://cdn.test/baseline.png', - }, - }, - { - id: 'cmp-2', - screenshot_name: 'Settings', - result: 'new', - needs_review: true, - }, - ], - comments: { - build: [{ id: 'comment-1', message: 'looks risky' }], - }, - links: { build_url: 'https://app.test/acme/web/builds/build-1' }, - }), - output, - exit: () => {}, - } - ); - - let dataCall = output.calls.find(call => call.method === 'data'); - let payload = dataCall.args[0]; - - assert.strictEqual(payload.resource, 'build_agent_context'); - assert.strictEqual(payload.project.organization, 'acme'); - assert.strictEqual(payload.build.id, 'build-1'); - assert.strictEqual(payload.baseline.selected.name, 'Approved Main'); - assert.strictEqual(payload.status.needs_review, true); - assert.deepStrictEqual(payload.dynamic_regions, { - policies: [ - { - region: { id: 'region-1', status: 'confirmed' }, - assignments: [{ screenshotName: 'Dashboard' }], - }, - ], - proposals: { proposals: [] }, - }); - assert.strictEqual(payload.evidence.length, 2); - assert.strictEqual(payload.evidence_limit, 10); - assert.strictEqual(payload.evidence_returned, 2); - assert.strictEqual(payload.evidence_truncated, false); - assert.strictEqual(payload.evidence[0].name, 'Dashboard'); - assert.strictEqual(payload.evidence[0].diff.region_count, 1); - assert.deepStrictEqual(payload.evidence[0].diff.projection, { - clusters: { count: 1 }, - distribution: 'header', - }); - assert.strictEqual(payload.evidence[0].review_state, 'pending'); - assert.strictEqual(payload.evidence[0].screenshot.browser, 'chrome'); - assert.deepStrictEqual(payload.evidence[0].screenshot.viewport, { - width: 1440, - height: 900, - }); - assert.strictEqual( - payload.evidence[0].screenshot.url, - 'https://cdn.test/current.png' - ); - assert.strictEqual( - payload.evidence[0].baseline.url, - 'https://cdn.test/baseline.png' - ); - assert.ok(!payload.evidence[0].diff.regions); - assert.ok(!payload.screenshots); - assert.ok(!payload.comments); - assert.ok(!payload.groups); - assert.ok(!payload.next_actions); + describe('option validation', () => { + it('accepts supported sources and compact agent options', () => { assert.deepStrictEqual( - payload.suggested_commands.map(item => item.command), - [ - 'vizzly --json context comparison cmp-1 --agent --source cloud', - 'vizzly --json context screenshot Dashboard --source cloud', - 'vizzly --json context build build-1 --agent --include diffs --source cloud', - ] + validateContextBuildOptions({ + source: 'cloud', + include: 'screenshots,diffs,comments', + offset: 0, + }), + [] ); }); - it('keeps one bounded breadth-first evidence queue in API order', async () => { - let output = createMockOutput(); - let groups = Array.from({ length: 6 }, (_, groupIndex) => ({ - name: `Group ${groupIndex + 1}`, - variant_count: 2, - aggregate_status: { - needs_review: true, - needs_review_count: 2, - }, - variants: Array.from({ length: 2 }, (_, variantIndex) => ({ - id: `cmp-${groupIndex + 1}-${variantIndex + 1}`, - result: 'changed', - needs_review: true, - })), - })); - - await contextBuildCommand( - 'build-1', - { agent: true }, - { json: true }, - { - loadConfig: async () => ({ - apiKey: 'token', - apiUrl: 'https://api.test', - }), - createApiClient: () => ({}), - getBuildContext: async () => ({ - build: { id: 'build-1' }, - groups, - }), - output, - exit: () => {}, - } - ); - - let payload = output.calls.find(call => call.method === 'data').args[0]; - assert.strictEqual(payload.evidence_returned, 10); - assert.strictEqual(payload.evidence_truncated, true); - assert.strictEqual(payload.evidence_offset, 0); - assert.strictEqual(payload.evidence_total, 12); - assert.strictEqual(payload.evidence_has_more, true); + it('rejects unsupported sources and compact agent fields', () => { assert.deepStrictEqual( - payload.evidence.map(item => item.id), + validateContextBuildOptions({ + source: 'moon', + include: 'screenshots,logs', + }), [ - 'cmp-1-1', - 'cmp-2-1', - 'cmp-3-1', - 'cmp-4-1', - 'cmp-5-1', - 'cmp-6-1', - 'cmp-1-2', - 'cmp-2-2', - 'cmp-3-2', - 'cmp-4-2', + '--source must be one of: auto, cloud, local', + '--include must contain only: screenshots, diffs, comments', ] ); - assert.ok( - payload.suggested_commands.some(item => - item.command.endsWith('--agent --full --source cloud') - ) - ); - assert.ok( - payload.suggested_commands.some(item => - item.command.endsWith('--agent --offset 10 --source cloud') - ) - ); - }); - - it('returns the requested API-ordered evidence page', async () => { - let output = createMockOutput(); - let groups = Array.from({ length: 12 }, (_, index) => ({ - name: `Group ${index + 1}`, - variant_count: 1, - aggregate_status: { needs_review: true, needs_review_count: 1 }, - variants: [ - { - id: `cmp-${index + 1}`, - result: 'changed', - needs_review: true, - }, - ], - })); - - await contextBuildCommand( - 'build-1', - { agent: true, offset: 10 }, - { json: true }, - { - loadConfig: async () => ({ - apiKey: 'token', - apiUrl: 'https://api.test', - }), - createApiClient: () => ({}), - getBuildContext: async () => ({ build: { id: 'build-1' }, groups }), - output, - exit: () => {}, - } - ); - - let payload = output.calls.find(call => call.method === 'data').args[0]; - assert.strictEqual(payload.evidence_offset, 10); - assert.strictEqual(payload.evidence_total, 12); - assert.strictEqual(payload.evidence_returned, 2); - assert.strictEqual(payload.evidence_has_more, false); - assert.strictEqual(payload.evidence_truncated, true); - assert.deepStrictEqual( - payload.evidence.map(item => item.id), - ['cmp-11', 'cmp-12'] - ); - assert.ok( - payload.suggested_commands.some(item => - item.command.endsWith( - '--agent --include diffs --offset 10 --source cloud' - ) - ) - ); - }); - - it('returns no compact evidence when the API marks every group reviewed', async () => { - let output = createMockOutput(); - - await contextBuildCommand( - 'build-1', - { agent: true }, - { json: true }, - { - loadConfig: async () => ({ - apiKey: 'token', - apiUrl: 'https://api.test', - }), - createApiClient: () => ({}), - getBuildContext: async () => ({ - build: { id: 'build-1' }, - groups: [ - { - name: 'Dashboard', - aggregate_status: { needs_review: false }, - variants: [ - { - id: 'cmp-approved', - result: 'changed', - needs_review: true, - }, - ], - }, - ], - }), - output, - exit: () => {}, - } - ); - - let payload = output.calls.find(call => call.method === 'data').args[0]; - assert.strictEqual(payload.status, null); - assert.strictEqual(payload.summary, null); - assert.strictEqual(payload.evidence_returned, 0); - assert.deepStrictEqual(payload.evidence, []); - assert.deepStrictEqual(payload.suggested_commands, []); - }); - - it('keeps failed captures actionable without inventing comparison IDs', async () => { - let output = createMockOutput(); - - await contextBuildCommand( - 'build-1', - { agent: true }, - { json: true }, - { - loadConfig: async () => ({ - apiKey: 'token', - apiUrl: 'https://api.test', - }), - createApiClient: () => ({}), - getBuildContext: async () => ({ - build: { id: 'build-1' }, - screenshots: [ - { - id: 'failed-screenshot', - name: 'Settings page', - status: 'failed', - error_message: 'Browser render failed', - url: 'https://cdn.test/failed.png', - }, - ], - }), - output, - exit: () => {}, - } - ); - - let payload = output.calls.find(call => call.method === 'data').args[0]; - assert.strictEqual(payload.evidence[0].kind, 'failed_capture'); - assert.strictEqual(payload.evidence[0].id, null); - assert.strictEqual( - payload.evidence[0].screenshot.id, - 'failed-screenshot' - ); - assert.strictEqual( - payload.evidence[0].error_message, - 'Browser render failed' - ); - assert.ok( - payload.suggested_commands.every( - item => !item.command.includes('context comparison') - ) - ); - assert.ok( - payload.suggested_commands.some(item => - item.command.includes("context screenshot 'Settings page'") - ) - ); - }); - - it('allows compact agent JSON to include requested detail', async () => { - let output = createMockOutput(); - - await contextBuildCommand( - 'build-1', - { agent: true, include: 'screenshots,diffs,comments' }, - { json: 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' }, - status: { needs_review: true, pending_comparisons: 1 }, - screenshots: [{ id: 'ss-1', name: 'Dashboard' }], - comparisons: [ - { - id: 'cmp-1', - screenshot_name: 'Dashboard', - result: 'changed', - diff: { - regions: [{ pixelCount: 50 }], - cluster_metadata: { clusterCount: 1 }, - }, - }, - ], - comments: { build: [{ id: 'comment-1' }] }, - }), - output, - exit: () => {}, - } - ); - - let payload = output.calls.find(call => call.method === 'data').args[0]; - assert.deepStrictEqual(payload.screenshots, [ - { id: 'ss-1', name: 'Dashboard' }, - ]); - assert.deepStrictEqual(payload.comments, { - build: [{ id: 'comment-1' }], - }); - assert.deepStrictEqual(payload.evidence[0].diff.regions, [ - { pixelCount: 50 }, - ]); - assert.deepStrictEqual(payload.evidence[0].diff.cluster_metadata, { - clusterCount: 1, - }); - }); - - it('returns the full payload when agent JSON asks for full context', async () => { - let output = createMockOutput(); - let capturedQuery = 'not-called'; - let context = { - resource: 'build_context', - build: { id: 'build-1' }, - screenshots: [{ id: 'ss-1' }], - }; - - await contextBuildCommand( - 'build-1', - { agent: true, full: true }, - { json: true }, - { - loadConfig: async () => ({ - apiKey: 'token', - apiUrl: 'https://api.test', - }), - createApiClient: () => ({}), - getBuildContext: async (_client, _buildId, query) => { - capturedQuery = query; - return context; - }, - output, - exit: () => {}, - } - ); - - let payload = output.calls.find(call => call.method === 'data').args[0]; - assert.strictEqual(payload.resource, 'build_context'); - assert.deepStrictEqual(payload.screenshots, [{ id: 'ss-1' }]); - assert.strictEqual(capturedQuery, undefined); - }); - - it('resolves cloud current builds from the active run session', async () => { - let output = createMockOutput(); - let capturedBuildId = null; - - await contextBuildCommand( - 'current', - { source: 'cloud', agent: true }, - { json: true }, - { - loadConfig: async () => ({ - apiKey: 'token', - apiUrl: 'https://api.test', - }), - createApiClient: () => ({}), - readSession: () => ({ - buildId: 'build-from-session', - source: 'session_file', - }), - getBuildContext: async (_client, buildId) => { - capturedBuildId = buildId; - return { - resource: 'build_context', - build: { id: buildId }, - comparisons: [], - }; - }, - output, - exit: () => {}, - } - ); - - assert.strictEqual(capturedBuildId, 'build-from-session'); - let payload = output.calls.find(call => call.method === 'data').args[0]; - assert.strictEqual(payload.build.id, 'build-from-session'); }); - it('uses local workspace context without requiring authentication', async () => { - let output = createMockOutput(); - - await contextBuildCommand( - 'current', - { source: 'local' }, - { json: true }, - { - loadConfig: async () => ({ - apiUrl: 'https://api.test', - }), - resolveContextSource: () => 'local', - createLocalWorkspaceContextProvider: () => ({ - getBuildContext: async () => ({ - resource: 'build_context', - source: 'local_workspace', - build: { id: 'local-build' }, - }), - }), - output, - exit: () => {}, - } - ); - - let dataCall = output.calls.find(call => call.method === 'data'); - assert.ok(dataCall); - assert.strictEqual(dataCall.args[0].source, 'local_workspace'); - assert.strictEqual(dataCall.args[0].build.id, 'local-build'); - }); - - it('uses comparison results in human output instead of completed status', async () => { - let output = createMockOutput(); - - await contextBuildCommand( - 'build-1', - {}, - {}, - { - loadConfig: async () => ({ - apiKey: 'token', - apiUrl: 'https://api.test', - }), - createApiClient: () => ({}), - getBuildContext: async () => ({ - build: { - id: 'build-1', - name: 'Context Build', - status: 'completed', - }, - scope: { - organization: { slug: 'acme' }, - project: { slug: 'storybook' }, - }, - summary: { review: { pending: 1, approved: 0, rejected: 0 } }, - review: { comments: [], assignments: [] }, - comparisons: [ - { - screenshot: { name: 'Dashboard' }, - status: 'completed', - result: 'changed', - diff_percentage: 1.5, - analysis: { fingerprint_hash: 'fp-dashboard' }, - }, - { - screenshot: { name: 'Settings' }, - status: 'completed', - result: 'new', - diff_percentage: 4.25, - analysis: { fingerprint_hash: 'fp-settings' }, - }, - ], - links: {}, - }), - output, - exit: () => {}, - } - ); - - let printLines = output.calls - .filter(call => call.method === 'print') - .map(call => call.args[0]); - assert.ok(printLines.some(line => line.includes('Dashboard CHANGED'))); - assert.ok(printLines.some(line => line.includes('Settings NEW'))); - }); - - 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'); + it('rejects invalid context limits and offsets', () => { assert.ok( - labels.some( - call => - call.args[0] === 'Baseline' && - call.args[1].includes('Approved Main') + validateContextComparisonOptions({ similarLimit: 51 }).includes( + '--similar-limit must be an integer between 1 and 50' ) ); 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('renders grouped review facts and failed captures without replacing server aggregates', 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: 'checkout' }, - }, - build: { - id: 'build-1', - name: 'Grouped Review Build', - status: 'completed', - }, - groups: [ - { - name: 'Checkout', - variant_count: 8, - aggregate_status: { - has_changes: false, - has_new: false, - all_approved: true, - needs_review: false, - needs_review_count: 0, - failed_count: 0, - max_diff_percentage: 0, - }, - variants: [ - { - id: 'partial-variant', - result: 'changed', - visual_review: { state: 'pending' }, - browser: 'chromium', - viewport: { width: 375, height: 667 }, - }, - ], - }, - { - name: 'Payment', - variant_count: 6, - aggregate_status: { - needs_review: true, - needs_review_count: 3, - failed_count: 2, - }, - variants: [], - }, - ], - screenshots: [ - { - id: 'failed-capture', - name: 'Settings', - status: 'failed', - error_message: 'Browser render failed', - browser: 'webkit', - viewport: { width: 1280, height: 720 }, - url: 'https://cdn.test/failed-current.png', - }, - ], - }), - output, - exit: () => {}, - } - ); - - let lines = output.calls - .filter(call => call.method === 'print') - .map(call => call.args[0]); - let checkoutIndex = lines.findIndex(line => - line.includes('Checkout APPROVED') - ); - let paymentIndex = lines.findIndex(line => - line.includes('Payment FAILED') - ); - - assert.ok(lines.some(line => line.includes('Screenshot groups'))); - assert.ok(checkoutIndex >= 0); - assert.ok(paymentIndex > checkoutIndex); - assert.ok( - lines.some(line => line.includes('8 variants · 0 needs review')) - ); - assert.ok( - lines.some(line => - line.includes('6 variants · 3 needs review · 2 failed') + validateContextComparisonOptions({ recentLimit: 4.5 }).includes( + '--recent-limit must be an integer between 1 and 50' ) ); - assert.ok(lines.some(line => line.includes('Failed captures'))); - assert.ok(lines.some(line => line.includes('Settings FAILED'))); assert.ok( - lines.some( - line => - line.includes('Browser render failed') && - line.includes('https://cdn.test/failed-current.png') - ) - ); - }); - - 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', () => { - it('passes similarity and history limits through to the API helper', async () => { - let output = createMockOutput(); - let capturedQuery = null; - - await contextComparisonCommand( - 'comparison-1', - { similarLimit: 5, recentLimit: 4, windowSize: 12 }, - { json: true }, - { - loadConfig: async () => ({ - apiKey: 'token', - apiUrl: 'https://api.test', - }), - createApiClient: () => ({}), - getComparisonContext: async (_client, _id, query) => { - capturedQuery = query; - return { - resource: 'comparison_context', - comparison: { id: 'comparison-1' }, - }; - }, - output, - exit: () => {}, - } - ); - - assert.deepStrictEqual(capturedQuery, { - similarLimit: 5, - recentLimit: 4, - windowSize: 12, - }); - }); - - it('shows known region labels in human output', async () => { - let output = createMockOutput(); - - await contextComparisonCommand( - 'comparison-1', - {}, - {}, - { - loadConfig: async () => ({ - apiKey: 'token', - apiUrl: 'https://api.test', - }), - createApiClient: () => ({}), - getComparisonContext: async () => ({ - scope: { - organization: { slug: 'acme' }, - project: { slug: 'storybook' }, - }, - comparison: { - id: 'comparison-1', - result: 'changed', - status: 'completed', - screenshot: { name: 'Dashboard' }, - analysis: { - fingerprint_hash: 'fp-dashboard', - diff_image_url: 'https://cdn.test/diff.png', - }, - }, - history: { - similar_by_fingerprint: [], - recent_by_name: [], - confirmed_regions: [{ label: 'Known header copy band' }], - }, - review: { - build_comments: [], - screenshot_comments: [], - }, - links: {}, - }), - output, - exit: () => {}, - } - ); - - let knownRegionsCall = output.calls.find( - call => call.method === 'labelValue' && call.args[0] === 'Known Regions' - ); - assert.ok(knownRegionsCall); - assert.strictEqual(knownRegionsCall.args[1], 'Known header copy band'); - }); - - it('returns consistent API-backed comparison evidence for agents', async () => { - let output = createMockOutput(); - - await contextComparisonCommand( - 'comparison-1', - { agent: true }, - { json: true }, - { - loadConfig: async () => ({ - apiKey: 'token', - apiUrl: 'https://api.test', - }), - createApiClient: () => ({}), - getComparisonContext: async () => ({ - resource: 'comparison_context', - source: 'cloud', - review_flow: 'cricket_v1', - scope: { - organization: { slug: 'acme' }, - project: { slug: 'web' }, - }, - build: { id: 'build-1' }, - dynamic_regions: { - systemResolution: { - type: 'dynamic_region_containment', - contractVersion: 'dynamic-region-containment-v1', - evidence: { - id: 'evidence-1', - maskDigest: 'sha256:mask-1', - }, - policies: [ - { - id: 'region-1', - revision: 3, - geometry: { - anchorX: 'right', - anchorY: 'bottom', - width: 361, - height: 15, - }, - assignment: { - id: 'assignment-1', - screenshotName: 'Dashboard', - }, - }, - ], - }, - }, - comparison: { - id: 'comparison-1', - result: 'changed', - visual_review: { state: 'pending' }, - screenshot: { - id: 'current-1', - name: 'Dashboard', - url: 'https://cdn.test/current.png', - }, - baseline: { - id: 'baseline-1', - url: 'https://cdn.test/baseline.png', - }, - analysis: { - diff_image_url: 'https://cdn.test/diff.png', - fingerprint_hash: 'fingerprint-1', - diff_regions: [{ x: 10, y: 20, width: 30, height: 40 }], - cluster_metadata: { classification: 'dynamic_content' }, - }, - }, - dynamic_content: null, - history: { - similar_by_fingerprint: [], - recent_by_name: [], - hotspot_analysis: null, - confirmed_regions: [], - }, - review: { review_summary: { pending: 1 } }, - links: { comparison_url: 'https://app.test/comparison-1' }, - }), - output, - exit: () => {}, - } - ); - - let payload = output.calls.find(call => call.method === 'data').args[0]; - assert.strictEqual(payload.resource, 'comparison_agent_context'); - assert.strictEqual(payload.source, 'cloud'); - assert.strictEqual(payload.comparison.name, 'Dashboard'); - assert.strictEqual(payload.comparison.review_state, 'pending'); - assert.strictEqual( - payload.comparison.diff.image_url, - 'https://cdn.test/diff.png' - ); - assert.deepStrictEqual(payload.comparison.diff.regions, [ - { x: 10, y: 20, width: 30, height: 40 }, - ]); - assert.deepStrictEqual(payload.comparison.diff.cluster_metadata, { - classification: 'dynamic_content', - }); - assert.deepStrictEqual(payload.dynamic_regions, { - systemResolution: { - type: 'dynamic_region_containment', - contractVersion: 'dynamic-region-containment-v1', - evidence: { - id: 'evidence-1', - maskDigest: 'sha256:mask-1', - }, - policies: [ - { - id: 'region-1', - revision: 3, - geometry: { - anchorX: 'right', - anchorY: 'bottom', - width: 361, - height: 15, - }, - assignment: { - id: 'assignment-1', - screenshotName: 'Dashboard', - }, - }, - ], - }, - }); - assert.strictEqual(payload.dynamic_content, null); - assert.deepStrictEqual(payload.history.confirmed_regions, []); - }); - }); - - describe('contextScreenshotCommand', () => { - it('passes project scope and history options to screenshot context', async () => { - let output = createMockOutput(); - let capturedQuery = null; - - await contextScreenshotCommand( - 'Dashboard', - { project: 'storybook', org: 'acme', recentLimit: 3, windowSize: 11 }, - { json: true }, - { - loadConfig: async () => ({ - apiKey: 'token', - apiUrl: 'https://api.test', - }), - createApiClient: () => ({}), - getScreenshotContext: async (_client, _name, query) => { - capturedQuery = query; - return { - resource: 'screenshot_context', - screenshot: { name: 'Dashboard' }, - }; - }, - output, - exit: () => {}, - } - ); - - assert.deepStrictEqual(capturedQuery, { - project: 'storybook', - organization: 'acme', - recentLimit: 3, - windowSize: 11, - }); - }); - - it('shows confirmed region labels in human output', async () => { - let output = createMockOutput(); - - await contextScreenshotCommand( - 'Dashboard', - { project: 'storybook', org: 'acme' }, - {}, - { - loadConfig: async () => ({ - apiKey: 'token', - apiUrl: 'https://api.test', - }), - createApiClient: () => ({}), - getScreenshotContext: async () => ({ - scope: { - organization: { slug: 'acme' }, - project: { slug: 'storybook' }, - }, - screenshot: { name: 'Dashboard' }, - hotspot_analysis: { - total_builds_analyzed: 2, - confidence: 'high', - }, - confirmed_regions: [{ label: 'Known header copy band' }], - history: { recent_comparisons: [] }, - }), - output, - exit: () => {}, - } - ); - - let knownRegionsCall = output.calls.find( - call => call.method === 'labelValue' && call.args[0] === 'Known Regions' - ); - assert.ok(knownRegionsCall); - assert.strictEqual(knownRegionsCall.args[1], 'Known header copy band'); - }); - }); - - describe('contextSimilarCommand', () => { - it('passes project-scoped fingerprint search options', async () => { - let output = createMockOutput(); - let capturedQuery = null; - - await contextSimilarCommand( - 'fp-dashboard', - { project: 'storybook', org: 'acme', limit: 7 }, - { json: true }, - { - loadConfig: async () => ({ - apiKey: 'token', - apiUrl: 'https://api.test', - }), - createApiClient: () => ({}), - getSimilarFingerprintContext: async (_client, _hash, query) => { - capturedQuery = query; - return { resource: 'fingerprint_context', comparisons: [] }; - }, - output, - exit: () => {}, - } - ); - - assert.deepStrictEqual(capturedQuery, { - project: 'storybook', - organization: 'acme', - limit: 7, - }); - }); - - it('fails clearly when local fingerprint similarity is unavailable', async () => { - let output = createMockOutput(); - let exitCode = null; - - await contextSimilarCommand( - 'fp-dashboard', - { source: 'local' }, - {}, - { - loadConfig: async () => ({ - apiUrl: 'https://api.test', - }), - resolveContextSource: () => 'local', - createLocalWorkspaceContextProvider: () => ({ - getSimilarFingerprintContext: async () => { - throw new Error( - 'Local workspace context does not support fingerprint similarity yet. Use --source cloud for this query.' - ); - }, - }), - output, - exit: code => { - exitCode = code; - }, - } - ); - - assert.strictEqual(exitCode, 1); - assert.ok( - output.calls.some( - call => - call.method === 'error' && - call.args[0] === 'Failed to fetch similar visual context' + validateContextReviewQueueOptions({ offset: -1 }).includes( + '--offset must be a non-negative integer' ) ); - }); - - it('explains the local similarity gap before an auth error in auto mode', async () => { - let output = createMockOutput(); - let exitCode = null; - - await contextSimilarCommand( - 'fp-dashboard', - {}, - {}, - { - loadConfig: async () => ({ - apiUrl: 'https://api.test', - }), - createLocalWorkspaceContextProvider: () => ({ - isAvailable: () => true, - canHandle: () => false, - }), - output, - exit: code => { - exitCode = code; - }, - } - ); - - assert.strictEqual(exitCode, 1); - let errorCall = output.calls.find(call => call.method === 'error'); - assert.ok(errorCall); - assert.strictEqual( - errorCall.args[1]?.message, - 'Local workspace context does not support fingerprint similarity yet. Use --source cloud for this query.' - ); - }); - - it('renders nested fingerprint hashes in human output', async () => { - let output = createMockOutput(); - - await contextSimilarCommand( - 'fp-dashboard', - { project: 'storybook', org: 'acme' }, - {}, - { - loadConfig: async () => ({ - apiKey: 'token', - apiUrl: 'https://api.test', - }), - createApiClient: () => ({}), - getSimilarFingerprintContext: async () => ({ - scope: { - organization: { slug: 'acme' }, - project: { slug: 'storybook' }, - }, - fingerprint: { hash: 'fp-dashboard' }, - matches: [], - }), - output, - exit: () => {}, - } - ); - assert.ok( - output.calls.some( - call => - call.method === 'print' && call.args[0].includes('fp-dashboard') + validateContextSimilarOptions({ limit: 0 }).includes( + '--limit must be an integer between 1 and 50' ) ); }); - }); - - describe('contextReviewQueueCommand', () => { - it('uses cloud in auto mode when project scope is explicit', async () => { - let output = createMockOutput(); - let capturedRuntimeOptions = null; - - await contextReviewQueueCommand( - { org: 'acme', project: 'storybook', limit: 5 }, - { json: true }, - { - loadConfig: async () => ({ - apiKey: 'token', - apiUrl: 'https://api.test', - }), - resolveContextSource: runtimeOptions => { - capturedRuntimeOptions = runtimeOptions; - return 'cloud'; - }, - createLocalWorkspaceContextProvider: () => ({ - isAvailable: () => true, - canHandle: () => true, - }), - createApiClient: () => ({}), - getReviewQueueContext: async () => ({ - resource: 'review_queue_context', - source: 'cloud', - scope: { - organization: { slug: 'acme' }, - project: { slug: 'storybook' }, - }, - summary: { total: 0, changed: 0, new: 0, builds: 0 }, - comparisons: [], - }), - output, - exit: () => {}, - } - ); - - assert.strictEqual(capturedRuntimeOptions.hasCloudScope, true); - let dataCall = output.calls.find(call => call.method === 'data'); - assert.strictEqual(dataCall.args[0].source, 'cloud'); - }); - - it('passes review queue scope and pagination to the API helper', async () => { - let output = createMockOutput(); - let capturedQuery = null; - await contextReviewQueueCommand( - { - project: 'storybook', - org: 'acme', - limit: 15, - offset: 30, - source: 'cloud', - }, - { json: true }, - { - loadConfig: async () => ({ - apiKey: 'token', - apiUrl: 'https://api.test', - }), - createApiClient: () => ({}), - getReviewQueueContext: async (_client, query) => { - capturedQuery = query; - return { resource: 'review_queue_context', comparisons: [] }; - }, - output, - exit: () => {}, - } + it('requires a project when screenshot context is scoped to an organization', () => { + assert.deepStrictEqual( + validateContextScreenshotOptions({ org: 'acme' }), + ['--org requires --project'] ); - - assert.deepStrictEqual(capturedQuery, { - project: 'storybook', - organization: 'acme', - limit: 15, - offset: 30, - }); }); }); }); diff --git a/tests/commands/doctor.test.js b/tests/commands/doctor.test.js index 6143bd8e..268bfaad 100644 --- a/tests/commands/doctor.test.js +++ b/tests/commands/doctor.test.js @@ -6,7 +6,6 @@ import { getApiUrlCheck, getNodeVersionCheck, getThresholdCheck, - validateDoctorOptions, } from '../../src/commands/doctor.js'; function createMockOutput() { @@ -55,12 +54,6 @@ let validConfig = { }; describe('commands/doctor', () => { - describe('validateDoctorOptions', () => { - it('returns no errors', () => { - assert.deepStrictEqual(validateDoctorOptions({}), []); - }); - }); - describe('diagnostic helpers', () => { it('creates the default diagnostics shape', () => { assert.deepStrictEqual(createDoctorDiagnostics(), { @@ -157,35 +150,20 @@ describe('commands/doctor', () => { it('checks API connectivity when requested', async () => { let output = createMockOutput(); - let capturedClientOptions = null; - let capturedBuildOptions = null; await doctorCommand( { api: true }, { json: true }, { - createApiClient: options => { - capturedClientOptions = options; - return { kind: 'client' }; - }, + createApiClient: () => ({ kind: 'client' }), getApiToken: () => null, - getBuilds: async (_client, options) => { - capturedBuildOptions = options; - return { builds: [] }; - }, + getBuilds: async () => ({ builds: [] }), loadConfig: async () => validConfig, nodeVersion: 'v22.2.0', output, } ); - assert.deepStrictEqual(capturedClientOptions, { - baseUrl: 'https://api.example.test', - token: 'token-123', - command: 'doctor', - }); - assert.deepStrictEqual(capturedBuildOptions, { limit: 1 }); - let dataCall = output.calls.find(call => call.method === 'data'); assert.strictEqual( dataCall.args[0].diagnostics.connectivity.checked, diff --git a/tests/commands/finalize.test.js b/tests/commands/finalize.test.js index 01303aa3..54866db0 100644 --- a/tests/commands/finalize.test.js +++ b/tests/commands/finalize.test.js @@ -87,45 +87,6 @@ describe('commands/finalize', () => { assert.ok(output.calls.some(c => c.method === 'error')); }); - it('calls finalizeParallelBuild with correct params', async () => { - let output = createMockOutput(); - let capturedClient = null; - let capturedParallelId = null; - - let result = await finalizeCommand( - 'parallel-123', - {}, - {}, - { - loadConfig: async () => ({ - apiKey: 'test-token', - apiUrl: 'https://api.test', - }), - createApiClient: opts => { - capturedClient = opts; - return { request: async () => ({}) }; - }, - finalizeParallelBuild: async (_client, parallelId) => { - capturedParallelId = parallelId; - return { - build: { - id: 'build-456', - status: 'completed', - parallel_id: 'parallel-123', - }, - }; - }, - output, - exit: () => {}, - } - ); - - assert.strictEqual(result.success, true); - assert.strictEqual(capturedParallelId, 'parallel-123'); - assert.strictEqual(capturedClient.token, 'test-token'); - assert.strictEqual(capturedClient.command, 'finalize'); - }); - it('outputs JSON when json flag is set', async () => { let output = createMockOutput(); @@ -274,35 +235,6 @@ describe('commands/finalize', () => { assert.ok(output.calls.some(c => c.method === 'cleanup')); }); - it('merges options and globalOptions for config', async () => { - let output = createMockOutput(); - let capturedConfigPath = null; - let capturedOptions = null; - - await finalizeCommand( - 'parallel-123', - { token: 'option-token' }, - { config: '/path/to/config', verbose: true }, - { - loadConfig: async (configPath, options) => { - capturedConfigPath = configPath; - capturedOptions = options; - return { apiKey: 'test-token', apiUrl: 'https://api.test' }; - }, - createApiClient: () => ({ request: async () => ({}) }), - finalizeParallelBuild: async () => ({ - build: { id: 'b', status: 'done', parallel_id: 'p' }, - }), - output, - exit: () => {}, - } - ); - - assert.strictEqual(capturedConfigPath, '/path/to/config'); - assert.strictEqual(capturedOptions.token, 'option-token'); - assert.strictEqual(capturedOptions.verbose, true); - }); - it('does not fail CI when API returns 5xx error', async () => { let output = createMockOutput(); let exitCode = null; diff --git a/tests/commands/login.test.js b/tests/commands/login.test.js index f3467408..3d8a8df3 100644 --- a/tests/commands/login.test.js +++ b/tests/commands/login.test.js @@ -6,7 +6,6 @@ import { loginCommand, normalizeDeviceFlowResponse, resolveAuthorizedTokenData, - validateLoginOptions, } from '../../src/commands/login.js'; function createMockOutput() { @@ -112,12 +111,6 @@ function createLoginHarness({ browserOpened = true, pollResponse } = {}) { } describe('commands/login', () => { - describe('validateLoginOptions', () => { - it('returns no errors', () => { - assert.deepStrictEqual(validateLoginOptions({}), []); - }); - }); - describe('device flow helpers', () => { it('normalizes snake_case and camelCase device flow responses', () => { assert.deepStrictEqual( diff --git a/tests/commands/logout.test.js b/tests/commands/logout.test.js index 549376d6..2e529989 100644 --- a/tests/commands/logout.test.js +++ b/tests/commands/logout.test.js @@ -1,9 +1,6 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { - logoutCommand, - validateLogoutOptions, -} from '../../src/commands/logout.js'; +import { logoutCommand } from '../../src/commands/logout.js'; function createMockOutput() { let calls = []; @@ -31,12 +28,6 @@ let auth = { }; describe('commands/logout', () => { - describe('validateLogoutOptions', () => { - it('returns no errors', () => { - assert.deepStrictEqual(validateLogoutOptions({}), []); - }); - }); - describe('logoutCommand', () => { it('returns not_logged_in JSON when no token is stored', async () => { let output = createMockOutput(); @@ -60,29 +51,19 @@ describe('commands/logout', () => { it('logs out with the configured auth client and token store', async () => { let output = createMockOutput(); - let capturedBaseUrl = null; - let capturedTokenStore = null; await logoutCommand( { apiUrl: 'https://api.example.test' }, { json: true }, { getAuthTokens: async () => auth, - createAuthClient: ({ baseUrl }) => { - capturedBaseUrl = baseUrl; - return { kind: 'client' }; - }, + createAuthClient: () => ({ kind: 'client' }), createTokenStore: () => ({ kind: 'store' }), - logout: async (_client, tokenStore) => { - capturedTokenStore = tokenStore; - }, + logout: async () => {}, output, } ); - assert.strictEqual(capturedBaseUrl, 'https://api.example.test'); - assert.deepStrictEqual(capturedTokenStore, { kind: 'store' }); - let dataCall = output.calls.find(call => call.method === 'data'); assert.deepStrictEqual(dataCall.args[0], { loggedOut: true }); }); diff --git a/tests/commands/orgs.test.js b/tests/commands/orgs.test.js index 1a69c008..6b89eb68 100644 --- a/tests/commands/orgs.test.js +++ b/tests/commands/orgs.test.js @@ -1,6 +1,6 @@ import assert from 'node:assert'; import { describe, it } from 'node:test'; -import { orgsCommand, validateOrgsOptions } from '../../src/commands/orgs.js'; +import { orgsCommand } from '../../src/commands/orgs.js'; /** * Create mock output object that tracks calls @@ -48,13 +48,6 @@ let mockOrgs = [ ]; describe('commands/orgs', () => { - describe('validateOrgsOptions', () => { - it('returns no errors', () => { - let errors = validateOrgsOptions({}); - assert.deepStrictEqual(errors, []); - }); - }); - describe('orgsCommand', () => { it('requires API token when no auth exists', async () => { let output = createMockOutput(); diff --git a/tests/commands/preview-cli.test.js b/tests/commands/preview-cli.test.js index 124c7840..f1b19eb2 100644 --- a/tests/commands/preview-cli.test.js +++ b/tests/commands/preview-cli.test.js @@ -1,7 +1,15 @@ import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, it } from 'node:test'; import { parseJSONOutput, runCLI } from '../helpers/cli-runner.js'; +function createWorkspace() { + return mkdtempSync(join(tmpdir(), 'vizzly-cli-preview-')); +} + describe('commands/preview CLI', () => { it('keeps --json missing-path output machine-readable', async () => { let result = await runCLI(['--no-color', '--json', 'preview']); @@ -32,4 +40,142 @@ describe('commands/preview CLI', () => { assert.match(result.stderr, /does not exist/); assert.doesNotMatch(result.stderr, /API token required/); }); + + it('shows the files selected for a dry run without requiring an API token', async () => { + let workspace = createWorkspace(); + let previewDir = join(workspace, 'dist'); + mkdirSync(join(previewDir, 'assets'), { recursive: true }); + writeFileSync(join(previewDir, 'index.html'), ''); + writeFileSync(join(previewDir, 'assets', 'app.js'), 'console.log(1)'); + writeFileSync(join(previewDir, 'package.json'), '{}'); + + let result = await runCLI( + [ + '--no-color', + '--json', + 'preview', + previewDir, + '--build', + 'build-123', + '--dry-run', + ], + { cwd: workspace } + ); + + assert.strictEqual(result.code, 0); + assert.strictEqual(result.stderr, ''); + + let payload = JSON.parse(result.stdout); + assert.strictEqual(payload.status, 'data'); + assert.strictEqual(payload.data.dryRun, true); + assert.strictEqual(payload.data.fileCount, 2); + assert.deepStrictEqual(payload.data.files.map(file => file.path).sort(), [ + 'assets/app.js', + 'index.html', + ]); + }); + + it('uploads a preview through the CLI and returns the server URL', async () => { + let workspace = createWorkspace(); + let previewDir = join(workspace, 'dist'); + mkdirSync(join(previewDir, 'assets'), { recursive: true }); + writeFileSync(join(previewDir, 'index.html'), ''); + writeFileSync(join(previewDir, 'assets', 'app.js'), 'console.log(1)'); + writeFileSync(join(previewDir, 'package.json'), '{}'); + + await withPreviewApiServer(async ({ apiUrl, requests }) => { + let result = await runCLI( + ['--no-color', '--json', 'preview', previewDir, '--build', 'build-123'], + { + cwd: workspace, + env: { + VIZZLY_API_URL: apiUrl, + VIZZLY_TOKEN: 'vzt_test_token', + }, + } + ); + + assert.strictEqual(result.code, 0); + assert.strictEqual(result.stderr, ''); + + let payload = JSON.parse(result.stdout); + assert.strictEqual(payload.status, 'data'); + assert.strictEqual(payload.data.success, true); + assert.strictEqual(payload.data.buildId, 'build-123'); + assert.strictEqual( + payload.data.previewUrl, + 'https://preview.test/build-123' + ); + assert.strictEqual(payload.data.files, 2); + assert.ok(requests[1].body.includes('index.html')); + assert.ok(requests[1].body.includes('assets/app.js')); + assert.strictEqual(requests[1].body.includes('package.json'), false); + assert.deepStrictEqual( + requests.map(request => `${request.method} ${request.url}`), + [ + 'GET /api/sdk/builds/build-123', + 'POST /api/sdk/builds/build-123/preview/upload-zip', + ] + ); + assert.match(requests[1].contentType, /multipart\/form-data/); + }); + }); }); + +async function withPreviewApiServer(callback) { + let requests = []; + let server = createServer(async (req, res) => { + let body = await readRequestBody(req); + requests.push({ + method: req.method, + url: req.url, + body, + contentType: req.headers['content-type'] || '', + }); + res.setHeader('content-type', 'application/json'); + + if (req.method === 'GET' && req.url === '/api/sdk/builds/build-123') { + return respond(res, { project: { isPublic: true } }); + } + + if ( + req.method === 'POST' && + req.url === '/api/sdk/builds/build-123/preview/upload-zip' + ) { + return respond(res, { + previewUrl: 'https://preview.test/build-123', + uploaded: 2, + totalBytes: body.length, + newBytes: body.length, + reusedBlobs: 0, + }); + } + + res.statusCode = 404; + return respond(res, { error: 'not found' }); + }); + + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + + try { + let address = server.address(); + return await callback({ + apiUrl: `http://127.0.0.1:${address.port}`, + requests, + }); + } finally { + await new Promise(resolve => server.close(resolve)); + } +} + +function readRequestBody(req) { + return new Promise(resolve => { + let chunks = []; + req.on('data', chunk => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks))); + }); +} + +function respond(res, payload) { + res.end(JSON.stringify(payload)); +} diff --git a/tests/commands/preview.test.js b/tests/commands/preview.test.js index 343f1a6c..1e5ac1d0 100644 --- a/tests/commands/preview.test.js +++ b/tests/commands/preview.test.js @@ -1,983 +1,18 @@ -import assert from 'node:assert'; -import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterEach, beforeEach, describe, it } from 'node:test'; -import { - previewCommand, - validatePreviewOptions, -} from '../../src/commands/preview.js'; -import { TestRunner } from '../../src/services/test-runner.js'; -import { readSession, writeSession } from '../../src/utils/session.js'; - -/** - * Create mock output object that tracks calls - */ -function createMockOutput() { - let calls = []; - return { - calls, - configure: opts => calls.push({ method: 'configure', args: [opts] }), - info: msg => calls.push({ method: 'info', args: [msg] }), - debug: (msg, data) => calls.push({ method: 'debug', args: [msg, data] }), - error: (msg, err) => calls.push({ method: 'error', args: [msg, err] }), - success: msg => calls.push({ method: 'success', args: [msg] }), - warn: msg => calls.push({ method: 'warn', args: [msg] }), - hint: msg => calls.push({ method: 'hint', args: [msg] }), - progress: (msg, cur, tot) => - calls.push({ method: 'progress', args: [msg, cur, tot] }), - startSpinner: msg => calls.push({ method: 'startSpinner', args: [msg] }), - updateSpinner: msg => calls.push({ method: 'updateSpinner', args: [msg] }), - stopSpinner: () => calls.push({ method: 'stopSpinner', args: [] }), - cleanup: () => calls.push({ method: 'cleanup', args: [] }), - complete: (msg, opts) => - calls.push({ method: 'complete', args: [msg, opts] }), - keyValue: (data, opts) => - calls.push({ method: 'keyValue', args: [data, opts] }), - labelValue: (label, value, opts) => - calls.push({ method: 'labelValue', args: [label, value, opts] }), - blank: () => calls.push({ method: 'blank', args: [] }), - print: msg => calls.push({ method: 'print', args: [msg] }), - link: (_label, url) => url, - data: obj => calls.push({ method: 'data', args: [obj] }), - getColors: () => ({ - brand: { - textTertiary: s => s, - success: s => s, - danger: s => s, - error: s => s, - info: s => s, - textMuted: s => s, - }, - white: s => s, - green: s => s, - cyan: s => s, - success: s => s, - info: s => s, - dim: s => s, - underline: s => s, - }), - }; -} +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { validatePreviewOptions } from '../../src/commands/preview.js'; describe('validatePreviewOptions', () => { - it('passes with valid path', () => { - let errors = validatePreviewOptions('./dist', {}); - assert.strictEqual(errors.length, 0); - }); - - it('fails with missing path', () => { - let errors = validatePreviewOptions(null, {}); - assert.ok(errors.includes('Path to static files is required')); - }); - - it('fails with empty path', () => { - let errors = validatePreviewOptions('', {}); - assert.ok(errors.includes('Path to static files is required')); - }); - - it('fails with whitespace-only path', () => { - let errors = validatePreviewOptions(' ', {}); - assert.ok(errors.includes('Path to static files is required')); - }); -}); - -describe('previewCommand', () => { - let testDir; - let distDir; - - beforeEach(() => { - testDir = join(tmpdir(), `vizzly-preview-test-${Date.now()}`); - distDir = join(testDir, 'dist'); - mkdirSync(distDir, { recursive: true }); - - // Create some test files - writeFileSync(join(distDir, 'index.html'), ''); - writeFileSync(join(distDir, 'app.js'), 'console.log("hello")'); - mkdirSync(join(distDir, 'assets')); - writeFileSync(join(distDir, 'assets', 'style.css'), 'body {}'); - }); - - afterEach(() => { - rmSync(testDir, { recursive: true, force: true }); - }); - - it('fails without API token', async () => { - let output = createMockOutput(); - let exitCode = null; - - await previewCommand( - distDir, - {}, - {}, - { - loadConfig: async () => ({ apiKey: null }), - output, - exit: code => { - exitCode = code; - }, - } - ); - - assert.strictEqual(exitCode, 1); - assert.ok( - output.calls.some( - c => c.method === 'error' && c.args[0].includes('API token') - ), - 'Should show API token error' - ); - }); - - it('fails when path does not exist', async () => { - let output = createMockOutput(); - let exitCode = null; - - await previewCommand( - '/nonexistent/path', - {}, - {}, - { - loadConfig: async () => ({ - apiKey: 'test-token', - apiUrl: 'https://api.test', - }), - output, - exit: code => { - exitCode = code; - }, - } - ); - - assert.strictEqual(exitCode, 1); - assert.ok( - output.calls.some( - c => c.method === 'error' && c.args[0].includes('does not exist') - ), - 'Should show path not found error' - ); - }); - - it('fails when no build ID is found', async () => { - let output = createMockOutput(); - let exitCode = null; - - await previewCommand( - distDir, - {}, - {}, - { - loadConfig: async () => ({ - apiKey: 'test-token', - apiUrl: 'https://api.test', - }), - readSession: () => null, // No session - detectBranch: async () => 'main', - output, - exit: code => { - exitCode = code; - }, - } - ); - - assert.strictEqual(exitCode, 1); - assert.ok( - output.calls.some( - c => c.method === 'error' && c.args[0].includes('No build found') - ), - 'Should show no build found error' - ); - }); - - it('uses build ID from session when available', async () => { - let output = createMockOutput(); - let capturedBuildId = null; - - await previewCommand( - distDir, - {}, - {}, - { - loadConfig: async () => ({ - apiKey: 'test-token', - apiUrl: 'https://api.test', - }), - readSession: () => ({ - buildId: 'session-build-123', - source: 'session_file', - expired: false, - branchMismatch: false, - age: 60000, - }), - formatSessionAge: () => '1m ago', - detectBranch: async () => 'main', - createApiClient: () => ({ - request: async () => ({}), - }), - getBuild: async () => ({ project: { isPublic: true } }), - uploadPreviewZip: async (_client, buildId) => { - capturedBuildId = buildId; - return { - previewUrl: 'https://preview.test', - uploaded: 3, - totalBytes: 1000, - newBytes: 800, - reusedBlobs: 0, - }; - }, - output, - exit: () => {}, - } - ); - - assert.strictEqual(capturedBuildId, 'session-build-123'); - }); - - it('uses build ID written by testRunner.createBuild', async () => { - let output = createMockOutput(); - let capturedBuildId = null; - let buildIdFromRunner = 'api-build-from-test-runner'; - let branch = 'feature/storybook-session'; - - let testRunner = new TestRunner( - { - apiKey: 'test-token', - apiUrl: 'https://api.test', - comparison: {}, - }, - {}, - { - deps: { - spawn: () => { - throw new Error('spawn should not be called in createBuild test'); - }, - createApiClient: () => ({}), - createApiBuild: async () => ({ id: buildIdFromRunner }), - getBuild: async () => ({ id: buildIdFromRunner }), - finalizeApiBuild: async () => {}, - output: { - debug: () => {}, - }, - writeSession: session => { - writeSession(session, { cwd: testDir, env: {} }); - }, - createError: (message, code) => { - let error = new Error(message); - error.code = code; - return error; - }, - }, - } - ); - - await testRunner.createBuild( - { - buildName: 'Storybook Build', - branch, - commit: 'abc123def456', - parallelId: 'parallel-227', - }, - false - ); - - await previewCommand( - distDir, - {}, - {}, - { - loadConfig: async () => ({ - apiKey: 'test-token', - apiUrl: 'https://api.test', - }), - readSession: options => - readSession({ - ...options, - cwd: testDir, - env: {}, - }), - detectBranch: async () => branch, - createApiClient: () => ({}), - getBuild: async () => ({ project: { isPublic: true } }), - uploadPreviewZip: async (_client, buildId) => { - capturedBuildId = buildId; - return { - previewUrl: 'https://preview.test', - uploaded: 3, - totalBytes: 1000, - newBytes: 800, - reusedBlobs: 0, - }; - }, - output, - exit: () => {}, - } - ); - - assert.strictEqual(capturedBuildId, buildIdFromRunner); - }); - - it('uses explicit build ID from options over session', async () => { - let output = createMockOutput(); - let capturedBuildId = null; - - await previewCommand( - distDir, - { build: 'explicit-build-456' }, - {}, - { - loadConfig: async () => ({ - apiKey: 'test-token', - apiUrl: 'https://api.test', - }), - readSession: () => ({ - buildId: 'session-build-123', - source: 'session_file', - expired: false, - }), - detectBranch: async () => 'main', - createApiClient: () => ({}), - getBuild: async () => ({ project: { isPublic: true } }), - uploadPreviewZip: async (_client, buildId) => { - capturedBuildId = buildId; - return { - previewUrl: 'https://preview.test', - uploaded: 3, - totalBytes: 1000, - newBytes: 800, - }; - }, - output, - exit: () => {}, - } - ); - - assert.strictEqual(capturedBuildId, 'explicit-build-456'); - }); - - it('warns on branch mismatch', async () => { - let output = createMockOutput(); - let exitCode = null; - - await previewCommand( - distDir, - {}, - {}, - { - loadConfig: async () => ({ - apiKey: 'test-token', - apiUrl: 'https://api.test', - }), - readSession: () => ({ - buildId: 'build-123', - branch: 'main', - source: 'session_file', - expired: false, - branchMismatch: true, - }), - detectBranch: async () => 'feature-branch', - output, - exit: code => { - exitCode = code; - }, - } - ); - - assert.strictEqual(exitCode, 1); - assert.ok( - output.calls.some( - c => c.method === 'warn' && c.args[0].includes('different branch') - ), - 'Should warn about branch mismatch' - ); - }); - - it('outputs JSON when --json flag is set', async () => { - let output = createMockOutput(); - - await previewCommand( - distDir, - { build: 'build-123' }, - { json: true }, - { - loadConfig: async () => ({ - apiKey: 'test-token', - apiUrl: 'https://api.test', - }), - createApiClient: () => ({}), - getBuild: async () => ({ project: { isPublic: true } }), - uploadPreviewZip: async () => ({ - previewUrl: 'https://preview.test', - uploaded: 3, - totalBytes: 1000, - newBytes: 800, - deduplicationRatio: 0.2, - }), - output, - exit: () => {}, - } - ); - - let dataCall = output.calls.find(c => c.method === 'data'); - assert.ok(dataCall, 'Should output JSON data'); - assert.strictEqual(dataCall.args[0].success, true); - assert.strictEqual(dataCall.args[0].previewUrl, 'https://preview.test'); - assert.strictEqual(dataCall.args[0].buildId, 'build-123'); - }); - - it('reports zero uploaded files in JSON when the API returns uploaded: 0', async () => { - let output = createMockOutput(); - - await previewCommand( - distDir, - { build: 'build-123' }, - { json: true }, - { - loadConfig: async () => ({ - apiKey: 'test-token', - apiUrl: 'https://api.test', - }), - createApiClient: () => ({}), - getBuild: async () => ({ project: { isPublic: true } }), - createZipWithSystem: async (_sourceDir, outputPath) => { - writeFileSync(outputPath, 'zip'); - }, - uploadPreviewZip: async () => ({ - previewUrl: 'https://preview.test', - uploaded: 0, - totalBytes: 3, - newBytes: 0, - deduplicationRatio: 1, - }), - output, - exit: () => {}, - } - ); - - let dataCall = output.calls.find(c => c.method === 'data'); - assert.strictEqual(dataCall.args[0].files, 0); - assert.match(dataCall.args[0].compressionRatio, /^\d+%$/); - }); - - it('opens browser when --open flag is set', async () => { - let output = createMockOutput(); - let openedUrl = null; - - await previewCommand( - distDir, - { build: 'build-123', open: true }, - {}, - { - loadConfig: async () => ({ - apiKey: 'test-token', - apiUrl: 'https://api.test', - }), - createApiClient: () => ({}), - getBuild: async () => ({ project: { isPublic: true } }), - uploadPreviewZip: async () => ({ - previewUrl: 'https://preview.test', - uploaded: 3, - totalBytes: 1000, - newBytes: 800, - }), - openBrowser: async url => { - openedUrl = url; - return true; - }, - output, - exit: () => {}, - } - ); - - assert.strictEqual(openedUrl, 'https://preview.test'); - }); - - it('dry-run shows files without uploading', async () => { - let output = createMockOutput(); - let uploadCalled = false; - - let result = await previewCommand( - distDir, - { dryRun: true, build: 'build-123' }, - {}, - { - loadConfig: async () => ({ - apiKey: null, // No API key needed for dry-run - apiUrl: 'https://api.test', - }), - uploadPreviewZip: async () => { - uploadCalled = true; - return {}; - }, - output, - exit: () => {}, - } - ); - - assert.strictEqual(uploadCalled, false, 'Should not upload in dry-run'); - assert.strictEqual(result.dryRun, true); - assert.strictEqual(result.fileCount, 3); // index.html, app.js, assets/style.css - assert.ok(result.files.length > 0, 'Should return file list'); - }); - - it('dry-run outputs JSON when --json flag is set', async () => { - let output = createMockOutput(); - - await previewCommand( - distDir, - { dryRun: true, build: 'build-123' }, - { json: true }, - { - loadConfig: async () => ({ - apiKey: null, - apiUrl: 'https://api.test', - }), - output, - exit: () => {}, - } - ); - - let dataCall = output.calls.find(c => c.method === 'data'); - assert.ok(dataCall, 'Should output JSON data'); - assert.strictEqual(dataCall.args[0].dryRun, true); - assert.ok(dataCall.args[0].files.length > 0); - assert.ok(Array.isArray(dataCall.args[0].excludedDirs)); - assert.ok(Array.isArray(dataCall.args[0].excludedFiles)); - }); - - it('uses --base as the upload root in dry-run mode', async () => { - mkdirSync(join(distDir, 'public')); - writeFileSync(join(distDir, 'public', 'index.html'), ''); - let output = createMockOutput(); - - let result = await previewCommand( - distDir, - { dryRun: true, build: 'build-123', base: 'public' }, - { json: true }, - { - loadConfig: async () => ({ - apiKey: null, - apiUrl: 'https://api.test', - }), - output, - exit: () => {}, - } - ); - - let dataCall = output.calls.find(c => c.method === 'data'); - assert.strictEqual(result.fileCount, 1); - assert.strictEqual(dataCall.args[0].path, join(distDir, 'public')); - assert.deepStrictEqual( - dataCall.args[0].files.map(file => file.path), - ['index.html'] - ); - }); - - it('excludes files matching --exclude patterns', async () => { - let output = createMockOutput(); - - let result = await previewCommand( - distDir, - { dryRun: true, build: 'build-123', exclude: ['*.js'] }, - {}, - { - loadConfig: async () => ({ - apiKey: null, - apiUrl: 'https://api.test', - }), - output, - exit: () => {}, - } - ); - - let filePaths = result.files.map(f => f.path); - assert.ok( - !filePaths.some(p => p.endsWith('.js')), - 'Should exclude .js files' - ); - assert.ok( - filePaths.some(p => p.endsWith('.html')), - 'Should include .html files' - ); - }); - - it('treats regex syntax literally in --exclude patterns', async () => { - writeFileSync( - join(distDir, 'widget[primary]-main.js'), - 'console.log("skip")' - ); - writeFileSync(join(distDir, 'widgetp-main.js'), 'console.log("keep")'); - let output = createMockOutput(); - - let result = await previewCommand( - distDir, - { - dryRun: true, - build: 'build-123', - exclude: ['widget[primary]*.js'], - }, - {}, - { - loadConfig: async () => ({ - apiKey: null, - apiUrl: 'https://api.test', - }), - output, - exit: () => {}, - } - ); - - let filePaths = result.files.map(f => f.path); - assert.ok(!filePaths.includes('widget[primary]-main.js')); - assert.ok(filePaths.includes('widgetp-main.js')); - }); - - it('excludes directories matching --exclude patterns with trailing slash', async () => { - let output = createMockOutput(); - - let result = await previewCommand( - distDir, - { dryRun: true, build: 'build-123', exclude: ['assets/'] }, - {}, - { - loadConfig: async () => ({ - apiKey: null, - apiUrl: 'https://api.test', - }), - output, - exit: () => {}, - } - ); - - let filePaths = result.files.map(f => f.path); - assert.ok( - !filePaths.some(p => p.startsWith('assets/')), - 'Should exclude assets directory' - ); - assert.ok( - filePaths.some(p => p === 'index.html'), - 'Should include index.html' - ); + it('accepts a path to static files', () => { + assert.deepStrictEqual(validatePreviewOptions('./dist', {}), []); }); - it('includes files matching --include patterns that override defaults', async () => { - // Create a package.json file (normally excluded by default) - writeFileSync(join(distDir, 'package.json'), '{}'); - - let output = createMockOutput(); - - let result = await previewCommand( - distDir, - { dryRun: true, build: 'build-123', include: ['package.json'] }, - {}, - { - loadConfig: async () => ({ - apiKey: null, - apiUrl: 'https://api.test', - }), - output, - exit: () => {}, - } - ); - - let filePaths = result.files.map(f => f.path); - assert.ok( - filePaths.includes('package.json'), - 'Should include package.json when --include is used' - ); - }); - - it('excludes node_modules by default', async () => { - // Create a node_modules directory with a file - mkdirSync(join(distDir, 'node_modules', 'some-package'), { - recursive: true, - }); - writeFileSync( - join(distDir, 'node_modules', 'some-package', 'index.js'), - 'module.exports = {}' - ); - - let output = createMockOutput(); - - let result = await previewCommand( - distDir, - { dryRun: true, build: 'build-123' }, - {}, - { - loadConfig: async () => ({ - apiKey: null, - apiUrl: 'https://api.test', - }), - output, - exit: () => {}, - } - ); - - let filePaths = result.files.map(f => f.path); - assert.ok( - !filePaths.some(p => p.includes('node_modules')), - 'Should exclude node_modules by default' - ); - }); - - it('excludes config files by default', async () => { - // Create config files - writeFileSync(join(distDir, 'vite.config.js'), 'export default {}'); - writeFileSync(join(distDir, 'tsconfig.json'), '{}'); - - let output = createMockOutput(); - - let result = await previewCommand( - distDir, - { dryRun: true, build: 'build-123' }, - {}, - { - loadConfig: async () => ({ - apiKey: null, - apiUrl: 'https://api.test', - }), - output, - exit: () => {}, - } - ); - - let filePaths = result.files.map(f => f.path); - assert.ok( - !filePaths.includes('vite.config.js'), - 'Should exclude *.config.js' - ); - assert.ok( - !filePaths.includes('tsconfig.json'), - 'Should exclude tsconfig.json' - ); - }); - - it('fails when getBuild returns 404', async () => { - let output = createMockOutput(); - let exitCode = null; - - let result = await previewCommand( - distDir, - { build: 'nonexistent-build' }, - {}, - { - loadConfig: async () => ({ - apiKey: 'test-token', - apiUrl: 'https://api.test', - }), - createApiClient: () => ({}), - getBuild: async () => { - let error = new Error('Not found'); - error.status = 404; - throw error; - }, - output, - exit: code => { - exitCode = code; - }, - } - ); - - assert.strictEqual(exitCode, 1); - assert.strictEqual(result.success, false); - assert.strictEqual(result.reason, 'build-fetch-failed'); - assert.ok( - output.calls.some( - c => c.method === 'error' && c.args[0].includes('Build not found') - ), - 'Should show build not found error' - ); - }); - - it('fails when getBuild throws network error', async () => { - let output = createMockOutput(); - let exitCode = null; - - let result = await previewCommand( - distDir, - { build: 'build-123' }, - {}, - { - loadConfig: async () => ({ - apiKey: 'test-token', - apiUrl: 'https://api.test', - }), - createApiClient: () => ({}), - getBuild: async () => { - throw new Error('Network error'); - }, - output, - exit: code => { - exitCode = code; - }, - } - ); - - assert.strictEqual(exitCode, 1); - assert.strictEqual(result.success, false); - assert.strictEqual(result.reason, 'build-fetch-failed'); - assert.ok( - output.calls.some( - c => - c.method === 'error' && - c.args[0].includes('Failed to verify project visibility') - ), - 'Should show visibility check error' - ); - }); - - it('fails for private project without --public-link flag', async () => { - let output = createMockOutput(); - let exitCode = null; - - let result = await previewCommand( - distDir, - { build: 'build-123' }, - {}, - { - loadConfig: async () => ({ - apiKey: 'test-token', - apiUrl: 'https://api.test', - }), - createApiClient: () => ({}), - getBuild: async () => ({ - id: 'build-123', - project: { id: 'proj-1', name: 'Test Project', isPublic: false }, - }), - uploadPreviewZip: async () => { - throw new Error('Should not reach upload'); - }, - output, - exit: code => { - exitCode = code; - }, - } - ); - - assert.strictEqual(exitCode, 1); - assert.strictEqual(result.success, false); - assert.strictEqual(result.reason, 'private-project-no-flag'); - assert.ok( - output.calls.some( - c => c.method === 'error' && c.args[0].includes('private') - ), - 'Should show private project error' - ); - assert.ok( - output.calls.some( - c => c.method === 'print' && c.args[0].includes('--public-link') - ), - 'Should mention --public-link flag in output' - ); - }); - - it('succeeds for private project with --public-link flag', async () => { - let output = createMockOutput(); - let uploadCalled = false; - - let result = await previewCommand( - distDir, - { build: 'build-123', publicLink: true }, - {}, - { - loadConfig: async () => ({ - apiKey: 'test-token', - apiUrl: 'https://api.test', - }), - createApiClient: () => ({}), - getBuild: async () => ({ - id: 'build-123', - project: { id: 'proj-1', name: 'Test Project', isPublic: false }, - }), - uploadPreviewZip: async () => { - uploadCalled = true; - return { - previewUrl: 'https://preview.test', - uploaded: 3, - totalBytes: 1000, - newBytes: 800, - }; - }, - output, - exit: () => {}, - } - ); - - assert.strictEqual(uploadCalled, true, 'Should call upload'); - assert.strictEqual(result.success, true); - }); - - it('succeeds for public project without --public-link flag', async () => { - let output = createMockOutput(); - let uploadCalled = false; - - let result = await previewCommand( - distDir, - { build: 'build-123' }, - {}, - { - loadConfig: async () => ({ - apiKey: 'test-token', - apiUrl: 'https://api.test', - }), - createApiClient: () => ({}), - getBuild: async () => ({ - id: 'build-123', - project: { id: 'proj-1', name: 'Test Project', isPublic: true }, - }), - uploadPreviewZip: async () => { - uploadCalled = true; - return { - previewUrl: 'https://preview.test', - uploaded: 3, - totalBytes: 1000, - newBytes: 800, - }; - }, - output, - exit: () => {}, - } - ); - - assert.strictEqual(uploadCalled, true, 'Should call upload'); - assert.strictEqual(result.success, true); - }); - - it('uploads previews from paths with spaces', async () => { - let spacedDir = join(testDir, 'dist with spaces'); - mkdirSync(spacedDir, { recursive: true }); - writeFileSync(join(spacedDir, 'index.html'), ''); - - let output = createMockOutput(); - let uploadedBytes = 0; - - let result = await previewCommand( - spacedDir, - { build: 'build-123' }, - {}, - { - loadConfig: async () => ({ - apiKey: 'test-token', - apiUrl: 'https://api.test', - }), - createApiClient: () => ({}), - getBuild: async () => ({ - id: 'build-123', - project: { id: 'proj-1', name: 'Test Project', isPublic: true }, - }), - uploadPreviewZip: async (_client, _buildId, zipBuffer) => { - uploadedBytes = zipBuffer.length; - return { - previewUrl: 'https://preview.test', - uploaded: 1, - totalBytes: zipBuffer.length, - newBytes: zipBuffer.length, - }; - }, - output, - exit: () => {}, - } - ); - - assert.strictEqual(result.success, true); - assert.ok(uploadedBytes > 0, 'Should upload a generated ZIP'); + it('rejects missing and whitespace-only paths', () => { + assert.deepStrictEqual(validatePreviewOptions(null, {}), [ + 'Path to static files is required', + ]); + assert.deepStrictEqual(validatePreviewOptions(' ', {}), [ + 'Path to static files is required', + ]); }); }); diff --git a/tests/commands/run.test.js b/tests/commands/run.test.js index 2dfe6219..11f476c3 100644 --- a/tests/commands/run.test.js +++ b/tests/commands/run.test.js @@ -2,80 +2,26 @@ import assert from 'node:assert'; import { describe, it } from 'node:test'; import { resolveBuildDisplayUrl, - runCommand, validateRunOptions, } from '../../src/commands/run.js'; -/** - * Create mock output object that tracks calls - */ -function createMockOutput() { - let calls = []; - return { - calls, - configure: opts => calls.push({ method: 'configure', args: [opts] }), - info: msg => calls.push({ method: 'info', args: [msg] }), - debug: (msg, data) => calls.push({ method: 'debug', args: [msg, data] }), - error: (msg, err) => calls.push({ method: 'error', args: [msg, err] }), - warn: msg => calls.push({ method: 'warn', args: [msg] }), - success: msg => calls.push({ method: 'success', args: [msg] }), - print: msg => calls.push({ method: 'print', args: [msg] }), - data: d => calls.push({ method: 'data', args: [d] }), - startSpinner: msg => calls.push({ method: 'startSpinner', args: [msg] }), - stopSpinner: () => calls.push({ method: 'stopSpinner', args: [] }), - cleanup: () => calls.push({ method: 'cleanup', args: [] }), - // TUI helpers - complete: (msg, opts) => - calls.push({ method: 'complete', args: [msg, opts] }), - keyValue: (data, opts) => - calls.push({ method: 'keyValue', args: [data, opts] }), - labelValue: (label, value, opts) => - calls.push({ method: 'labelValue', args: [label, value, opts] }), - blank: () => calls.push({ method: 'blank', args: [] }), - link: (_label, url) => url, // Return the URL for testing - getColors: () => ({ - brand: { - textTertiary: s => s, - success: s => s, - warning: s => s, - danger: s => s, - error: s => s, - info: s => s, - textMuted: s => s, - }, - white: s => s, - cyan: s => s, - success: s => s, - info: s => s, - dim: s => s, - underline: s => s, - }), - }; -} - -/** - * Create minimal mock config - */ -function createMockConfig(overrides = {}) { +function createConfig(overrides = {}) { return { apiKey: 'test-token', apiUrl: 'https://api.test', - server: { port: 47392, timeout: 30000 }, - build: { environment: 'test' }, - comparison: { threshold: 0.1 }, ...overrides, }; } describe('commands/run', () => { describe('resolveBuildDisplayUrl', () => { - it('uses the API result URL when one is already present', async () => { + it('uses the URL returned by the API', async () => { let url = await resolveBuildDisplayUrl({ result: { buildId: 'build-123', url: 'https://app.test/acme/web/builds/build-123', }, - config: createMockConfig(), + config: createConfig(), createApiClient: () => { throw new Error('should not fetch token context'); }, @@ -85,41 +31,23 @@ describe('commands/run', () => { }); it('builds an organization/project URL from token context', async () => { - let clientArgs; let url = await resolveBuildDisplayUrl({ result: { buildId: 'build-123' }, - config: createMockConfig({ - apiUrl: 'https://api.test/api/v1', - apiKey: 'token-123', + config: createConfig({ apiUrl: 'https://api.test/api/v1' }), + createApiClient: () => ({}), + getTokenContext: async () => ({ + organization: { slug: 'acme' }, + project: { slug: 'web' }, }), - createApiClient: args => { - clientArgs = args; - return { client: true }; - }, - getTokenContext: async client => { - assert.deepStrictEqual(client, { client: true }); - return { - organization: { slug: 'acme' }, - project: { slug: 'web' }, - }; - }, }); - assert.deepStrictEqual(clientArgs, { - baseUrl: 'https://api.test/api/v1', - token: 'token-123', - command: 'run', - }); assert.strictEqual(url, 'https://api.test/acme/web/builds/build-123'); }); - it('falls back to the build URL when token context lookup fails', async () => { + it('falls back to the app build URL when token context is unavailable', async () => { let url = await resolveBuildDisplayUrl({ result: { buildId: 'build-123' }, - config: createMockConfig({ - apiUrl: 'https://api.test/api/v1', - apiKey: 'token-123', - }), + config: createConfig({ apiUrl: 'https://api.test/api/v1' }), createApiClient: () => ({ client: true }), getTokenContext: async () => { throw new Error('context unavailable'); @@ -129,1456 +57,65 @@ describe('commands/run', () => { assert.strictEqual(url, 'https://api.test/builds/build-123'); }); - it('returns undefined when no URL can be resolved without a token', async () => { + it('returns no URL when there is no API token or result URL', async () => { let url = await resolveBuildDisplayUrl({ result: { buildId: 'build-123' }, - config: createMockConfig({ apiKey: null }), + config: createConfig({ apiKey: null }), }); assert.strictEqual(url, undefined); }); }); - describe('runCommand', () => { - it('returns error when no API key and allowNoToken not set', async () => { - let output = createMockOutput(); - let exitCode = null; - - let result = await runCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => createMockConfig({ apiKey: null }), - output, - exit: code => { - exitCode = code; - }, - processOn: () => {}, - processRemoveListener: () => {}, - } - ); - - assert.strictEqual(result.success, false); - assert.strictEqual(result.reason, 'no-api-key'); - assert.strictEqual(exitCode, 1); - assert.ok(output.calls.some(c => c.method === 'error')); - }); - - it('allows running without token when allowNoToken is set', async () => { - let output = createMockOutput(); - let runTestsCalled = false; - - let result = await runCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => - createMockConfig({ apiKey: null, allowNoToken: true }), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - createUploader: () => ({ - waitForBuild: async () => ({}), - }), - runTests: async () => { - runTestsCalled = true; - return { buildId: null, screenshotsCaptured: 0 }; - }, - detectBranch: async () => 'main', - detectCommit: async () => 'abc123', - detectCommitMessage: async () => 'test', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async () => 'test-build', - output, - exit: () => {}, - processOn: () => {}, - processRemoveListener: () => {}, - } - ); - - assert.strictEqual(result.success, true); - assert.strictEqual(runTestsCalled, true); - }); - - it('configures output with global options', async () => { - let output = createMockOutput(); - - await runCommand( - 'pnpm test', - {}, - { json: true, verbose: true, noColor: true }, - { - loadConfig: async () => createMockConfig({ apiKey: null }), - output, - exit: () => {}, - processOn: () => {}, - processRemoveListener: () => {}, - } - ); - - let configureCall = output.calls.find(c => c.method === 'configure'); - assert.ok(configureCall); - assert.strictEqual(configureCall.args[0].json, true); - assert.strictEqual(configureCall.args[0].verbose, true); - assert.strictEqual(configureCall.args[0].color, false); - }); - - it('shows verbose output when verbose flag is set', async () => { - let output = createMockOutput(); - - await runCommand( - 'pnpm test', - {}, - { verbose: true }, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - createUploader: () => ({ - waitForBuild: async () => ({}), - }), - runTests: async () => ({ buildId: 'b-123', screenshotsCaptured: 5 }), - detectBranch: async () => 'main', - detectCommit: async () => 'abc123', - detectCommitMessage: async () => 'test', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async () => 'test-build', - output, - exit: () => {}, - processOn: () => {}, - processRemoveListener: () => {}, - } - ); - - assert.ok(output.calls.some(c => c.method === 'debug')); - assert.ok( - output.calls.some( - c => c.method === 'info' && c.args[0].includes('Token check') - ) - ); - }); - - it('handles successful test run', async () => { - let output = createMockOutput(); - - let result = await runCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - createUploader: () => ({ - waitForBuild: async () => ({}), - }), - runTests: async () => ({ - buildId: 'build-123', - screenshotsCaptured: 10, - url: 'https://vizzly.dev/builds/123', - }), - detectBranch: async () => 'main', - detectCommit: async () => 'abc123', - detectCommitMessage: async () => 'test commit', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async () => 'test-build', - output, - exit: () => {}, - processOn: () => {}, - processRemoveListener: () => {}, - } - ); - - assert.strictEqual(result.success, true); - assert.strictEqual(result.result.buildId, 'build-123'); - // Now uses output.complete() instead of output.success() - assert.ok(output.calls.some(c => c.method === 'complete')); - // Now uses print for screenshot summary - assert.ok(output.calls.some(c => c.method === 'print')); - let contextCall = output.calls.find( - call => - call.method === 'print' && - call.args[0].includes('vizzly context build build-123 --agent') - ); - assert.ok(contextCall); - assert.doesNotMatch(contextCall.args[0], /--json/); - }); - - it('handles test command failure with exit code', async () => { - let output = createMockOutput(); - - let error = new Error('Test command exited with code 2'); - error.code = 'TEST_COMMAND_FAILED'; - - let result = await runCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - createUploader: () => ({}), - runTests: async () => { - throw error; - }, - detectBranch: async () => 'main', - detectCommit: async () => 'abc123', - detectCommitMessage: async () => 'test', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async () => 'test-build', - output, - exit: () => {}, - processOn: () => {}, - processRemoveListener: () => {}, - } - ); - - assert.strictEqual(result.success, false); - assert.strictEqual(result.exitCode, 2); - }); - - it('handles generic error during test run', async () => { - let output = createMockOutput(); - - let result = await runCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - createUploader: () => ({}), - runTests: async () => { - throw new Error('Something went wrong'); - }, - detectBranch: async () => 'main', - detectCommit: async () => 'abc123', - detectCommitMessage: async () => 'test', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async () => 'test-build', - output, - exit: () => {}, - processOn: () => {}, - processRemoveListener: () => {}, - } - ); - - assert.strictEqual(result.success, false); - assert.strictEqual(result.exitCode, 1); - }); - - it('waits for build completion when --wait flag is set', async () => { - let output = createMockOutput(); - let waitForBuildCalled = false; - - let result = await runCommand( - 'pnpm test', - { wait: true }, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - createUploader: () => ({ - waitForBuild: async buildId => { - waitForBuildCalled = true; - assert.strictEqual(buildId, 'build-123'); - return { - status: 'completed', - failedComparisons: 0, - passedComparisons: 5, - }; - }, - }), - runTests: async () => ({ - buildId: 'build-123', - screenshotsCaptured: 5, - }), - detectBranch: async () => 'main', - detectCommit: async () => 'abc123', - detectCommitMessage: async () => 'test', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async () => 'test-build', - output, - exit: () => {}, - processOn: () => {}, - processRemoveListener: () => {}, - } - ); - - assert.strictEqual(result.success, true); - assert.strictEqual(waitForBuildCalled, true); - assert.ok( - output.calls.some( - c => c.method === 'info' && c.args[0].includes('Waiting for build') - ) - ); - }); - - it('returns failure when wait mode has failed comparisons', async () => { - let output = createMockOutput(); - - let result = await runCommand( - 'pnpm test', - { wait: true }, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - createUploader: () => ({ - waitForBuild: async () => ({ - status: 'completed', - failedComparisons: 3, - passedComparisons: 5, - }), - }), - runTests: async () => ({ - buildId: 'build-123', - screenshotsCaptured: 8, - }), - detectBranch: async () => 'main', - detectCommit: async () => 'abc123', - detectCommitMessage: async () => 'test', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async () => 'test-build', - output, - exit: () => {}, - processOn: () => {}, - processRemoveListener: () => {}, - } - ); - - assert.strictEqual(result.success, false); - assert.strictEqual(result.exitCode, 1); - assert.ok( - output.calls.some( - c => - c.method === 'error' && c.args[0].includes('3 visual comparisons') - ) - ); - }); - - it('keeps passed tests separate from an unavailable API build status', async () => { - let output = createMockOutput(); - let userTestsRan = false; - - let result = await runCommand( - 'pnpm test', - { wait: true }, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - createUploader: () => ({ - waitForBuild: async () => { - let error = new Error('Failed to check build status'); - error.code = 'BUILD_STATUS_FAILED'; - error.context = { status: 400 }; - throw error; - }, - }), - runTests: async () => { - userTestsRan = true; - return { - buildId: 'build-123', - screenshotsCaptured: 0, - }; - }, - detectBranch: async () => 'main', - detectCommit: async () => 'abc123', - detectCommitMessage: async () => 'test', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async () => 'test-build', - output, - exit: () => {}, - processOn: () => {}, - processRemoveListener: () => {}, - } - ); - - assert.strictEqual(userTestsRan, true); - assert.strictEqual(result.success, true); - assert.strictEqual(result.result.buildId, 'build-123'); - assert.strictEqual(result.result.status, null); - assert.strictEqual(result.result.testsPassed, true); - assert.deepStrictEqual(result.result.error, { - code: 'BUILD_STATUS_FAILED', - message: 'Failed to check build status', - }); - assert.ok( - output.calls.some( - call => - call.method === 'warn' && - call.args[0].includes('could not confirm the build status') - ) - ); - }); - - it('registers and removes process event listeners', async () => { - let output = createMockOutput(); - let registeredEvents = []; - let removedEvents = []; - - await runCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => createMockConfig({ apiKey: null }), - output, - exit: () => {}, - processOn: (event, handler) => { - registeredEvents.push({ event, handler }); - }, - processRemoveListener: (event, handler) => { - removedEvents.push({ event, handler }); - }, - } - ); - - assert.ok(registeredEvents.some(e => e.event === 'SIGINT')); - assert.ok(registeredEvents.some(e => e.event === 'exit')); - assert.ok(removedEvents.some(e => e.event === 'SIGINT')); - assert.ok(removedEvents.some(e => e.event === 'exit')); - }); - - it('handles config loading error', async () => { - let output = createMockOutput(); - let exitCode = null; - - let result = await runCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => { - throw new Error('Config file not found'); - }, - output, - exit: code => { - exitCode = code; - }, - processOn: () => {}, - processRemoveListener: () => {}, - } - ); - - assert.strictEqual(result.success, false); - assert.ok(result.error); - assert.strictEqual(exitCode, 1); - }); - - it('passes minClusterSize from config to runOptions', async () => { - // This test verifies the fix for issue #160 - let output = createMockOutput(); - let capturedRunOptions = null; - - await runCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => - createMockConfig({ - comparison: { threshold: 2.0, minClusterSize: 5 }, - }), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - createUploader: () => ({}), - runTests: async ({ runOptions }) => { - capturedRunOptions = runOptions; - return { buildId: null, screenshotsCaptured: 0 }; - }, - detectBranch: async () => 'main', - detectCommit: async () => 'abc123', - detectCommitMessage: async () => 'test', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async () => 'test-build', - output, - exit: () => {}, - processOn: () => {}, - processRemoveListener: () => {}, - } - ); - - assert.strictEqual( - capturedRunOptions.minClusterSize, - 5, - 'minClusterSize should be passed from config to runOptions' - ); - assert.strictEqual(capturedRunOptions.threshold, 2.0); - }); - - it('uses provided git metadata options', async () => { - let output = createMockOutput(); - let capturedRunOptions = null; - - await runCommand( - 'pnpm test', - { - branch: 'feature-branch', - commit: 'def456', - message: 'custom message', - buildName: 'custom-build', - }, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - createUploader: () => ({}), - runTests: async ({ runOptions }) => { - capturedRunOptions = runOptions; - return { buildId: null, screenshotsCaptured: 0 }; - }, - detectBranch: async branch => branch || 'default', - detectCommit: async commit => commit || 'default', - detectCommitMessage: async () => 'default message', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async name => name || 'default-name', - output, - exit: () => {}, - processOn: () => {}, - processRemoveListener: () => {}, - } - ); - - assert.strictEqual(capturedRunOptions.branch, 'feature-branch'); - assert.strictEqual(capturedRunOptions.commit, 'def456'); - assert.strictEqual(capturedRunOptions.message, 'custom message'); - assert.strictEqual(capturedRunOptions.buildName, 'custom-build'); - }); - - it('uses configured build metadata when CLI overrides are absent', async () => { - let output = createMockOutput(); - let capturedRunOptions = null; - - await runCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => - createMockConfig({ - build: { - name: 'Configured Run', - branch: 'config-branch', - commit: 'config-sha', - message: 'Config message', - environment: 'preview', - }, - }), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - createUploader: () => ({}), - runTests: async ({ runOptions }) => { - capturedRunOptions = runOptions; - return { buildId: null, screenshotsCaptured: 0 }; - }, - detectBranch: async branch => branch, - detectCommit: async commit => commit, - detectCommitMessage: async () => { - throw new Error('should not detect message'); - }, - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async name => name, - output, - exit: () => {}, - processOn: () => {}, - processRemoveListener: () => {}, - } - ); - - assert.strictEqual(capturedRunOptions.branch, 'config-branch'); - assert.strictEqual(capturedRunOptions.commit, 'config-sha'); - assert.strictEqual(capturedRunOptions.message, 'Config message'); - assert.strictEqual(capturedRunOptions.buildName, 'Configured Run'); - assert.strictEqual(capturedRunOptions.environment, 'preview'); - }); - - it('waits before emitting JSON when --wait and --json are combined', async () => { - let output = createMockOutput(); - - let result = await runCommand( - 'pnpm test', - { wait: true }, - { json: true }, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - createUploader: () => ({ - waitForBuild: async buildId => { - assert.strictEqual(buildId, 'build-123'); - return { - status: 'completed', - build: { conclusion: 'review_required' }, - totalComparisons: 3, - newComparisons: 1, - failedComparisons: 2, - identicalComparisons: 0, - approvalStatus: 'pending', - }; - }, - }), - runTests: async () => ({ - buildId: 'build-123', - screenshotsCaptured: 3, - url: 'https://app.test/builds/build-123', - }), - detectBranch: async () => 'main', - detectCommit: async () => 'abc123', - detectCommitMessage: async () => 'test', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async () => 'test-build', - output, - exit: () => {}, - processOn: () => {}, - processRemoveListener: () => {}, - } - ); - - let dataCalls = output.calls.filter(c => c.method === 'data'); - - assert.strictEqual(dataCalls.length, 1); - assert.strictEqual(dataCalls[0].args[0].status, 'completed'); - assert.strictEqual(dataCalls[0].args[0].conclusion, 'review_required'); - assert.deepStrictEqual(dataCalls[0].args[0].comparisons, { - total: 3, - new: 1, - changed: 2, - identical: 0, - }); - assert.strictEqual(dataCalls[0].args[0].exitCode, 1); - assert.strictEqual(result.success, false); - assert.strictEqual(result.exitCode, 1); - }); - - it('always calls cleanup', async () => { - let output = createMockOutput(); - - await runCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - createUploader: () => ({}), - runTests: async () => ({ buildId: 'b-1', screenshotsCaptured: 1 }), - detectBranch: async () => 'main', - detectCommit: async () => 'abc', - detectCommitMessage: async () => 'test', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async () => 'build', - output, - exit: () => {}, - processOn: () => {}, - processRemoveListener: () => {}, - } - ); - - assert.ok(output.calls.some(c => c.method === 'cleanup')); - }); - - it('invokes onBuildCreated callback', async () => { - let output = createMockOutput(); - - await runCommand( - 'pnpm test', - {}, - { verbose: true }, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - createUploader: () => ({}), - runTests: async ({ deps }) => { - // Simulate the callback being invoked - deps.onBuildCreated({ - buildId: 'build-456', - url: 'https://vizzly.dev/builds/456', - }); - return { buildId: 'build-456', screenshotsCaptured: 3 }; - }, - detectBranch: async () => 'main', - detectCommit: async () => 'abc', - detectCommitMessage: async () => 'test', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async () => 'build', - output, - exit: () => {}, - processOn: () => {}, - processRemoveListener: () => {}, - } - ); - - assert.ok( - output.calls.some( - c => c.method === 'info' && c.args[0].includes('build-456') - ) - ); - assert.ok( - output.calls.some( - c => c.method === 'info' && c.args[0].includes('Vizzly') - ) - ); - }); - - it('invokes onServerReady callback in verbose mode', async () => { - let output = createMockOutput(); - - await runCommand( - 'pnpm test', - {}, - { verbose: true }, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - createUploader: () => ({}), - runTests: async ({ deps }) => { - deps.onServerReady({ port: 47392 }); - return { buildId: null, screenshotsCaptured: 0 }; - }, - detectBranch: async () => 'main', - detectCommit: async () => 'abc', - detectCommitMessage: async () => 'test', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async () => 'build', - output, - exit: () => {}, - processOn: () => {}, - processRemoveListener: () => {}, - } - ); - - assert.ok( - output.calls.some( - c => c.method === 'info' && c.args[0].includes('47392') - ) - ); - }); - - it('invokes onFinalizeFailed callback', async () => { - let output = createMockOutput(); - - await runCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - createUploader: () => ({}), - runTests: async ({ deps }) => { - deps.onFinalizeFailed({ - buildId: 'build-789', - error: 'Network error', - }); - return { buildId: 'build-789', screenshotsCaptured: 1 }; - }, - detectBranch: async () => 'main', - detectCommit: async () => 'abc', - detectCommitMessage: async () => 'test', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async () => 'build', - output, - exit: () => {}, - processOn: () => {}, - processRemoveListener: () => {}, - } - ); - - assert.ok( - output.calls.some( - c => c.method === 'warn' && c.args[0].includes('build-789') - ) - ); - }); - - it('handles TEST_COMMAND_INTERRUPTED error code', async () => { - let output = createMockOutput(); - - let error = new Error('Test interrupted'); - error.code = 'TEST_COMMAND_INTERRUPTED'; - - let result = await runCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - createUploader: () => ({}), - runTests: async () => { - throw error; - }, - detectBranch: async () => 'main', - detectCommit: async () => 'abc', - detectCommitMessage: async () => 'test', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async () => 'build', - output, - exit: () => {}, - processOn: () => {}, - processRemoveListener: () => {}, - } - ); - - assert.strictEqual(result.success, false); - assert.strictEqual(result.exitCode, 1); - }); - - it('provides contextual error for build failures', async () => { - let output = createMockOutput(); - - let result = await runCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => { - throw new Error('build creation failed'); - }, - output, - exit: () => {}, - processOn: () => {}, - processRemoveListener: () => {}, - } - ); - - assert.strictEqual(result.success, false); - assert.ok( - output.calls.some( - c => c.method === 'error' && c.args[0].includes('Build creation') - ) - ); - }); - - it('provides contextual error for screenshot failures', async () => { - let output = createMockOutput(); - - let result = await runCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => { - throw new Error('screenshot processing error'); - }, - output, - exit: () => {}, - processOn: () => {}, - processRemoveListener: () => {}, - } - ); - - assert.strictEqual(result.success, false); - assert.ok( - output.calls.some( - c => c.method === 'error' && c.args[0].includes('Screenshot') - ) - ); - }); - - it('provides contextual error for server failures', async () => { - let output = createMockOutput(); - - let result = await runCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => { - throw new Error('server startup failed'); - }, - output, - exit: () => {}, - processOn: () => {}, - processRemoveListener: () => {}, - } - ); - - assert.strictEqual(result.success, false); - assert.ok( - output.calls.some( - c => c.method === 'error' && c.args[0].includes('Server startup') - ) - ); - }); - - it('SIGINT handler triggers cleanup and exit', async () => { - let output = createMockOutput(); - let handlers = {}; - let exitCode = null; - let serverStopped = false; - - // Start a run that will hang on runTests - let runPromise = runCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => { - serverStopped = true; - }, - }), - createUploader: () => ({}), - runTests: async () => { - // Simulate SIGINT during test run - await handlers.SIGINT(); - return { buildId: null, screenshotsCaptured: 0 }; - }, - detectBranch: async () => 'main', - detectCommit: async () => 'abc', - detectCommitMessage: async () => 'test', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async () => 'build', - output, - exit: code => { - exitCode = code; - }, - processOn: (event, handler) => { - handlers[event] = handler; - }, - processRemoveListener: () => {}, - } - ); - - await runPromise; - - assert.strictEqual(exitCode, 1); - assert.strictEqual(serverStopped, true); - assert.ok(output.calls.some(c => c.method === 'cleanup')); - }); - - it('exit handler calls output.cleanup', async () => { - let output = createMockOutput(); - let handlers = {}; - - await runCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - createUploader: () => ({}), - runTests: async () => { - // Trigger exit handler during test - handlers.exit(); - return { buildId: null, screenshotsCaptured: 0 }; - }, - detectBranch: async () => 'main', - detectCommit: async () => 'abc', - detectCommitMessage: async () => 'test', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async () => 'build', - output, - exit: () => {}, - processOn: (event, handler) => { - handlers[event] = handler; - }, - processRemoveListener: () => {}, - } - ); - - // exit handler should have been called, triggering cleanup - assert.ok(output.calls.filter(c => c.method === 'cleanup').length >= 1); - }); - - it('cleanup kills test process if running', async () => { - let output = createMockOutput(); - let handlers = {}; - let processKilled = false; - - await runCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - createUploader: () => ({}), - runTests: async ({ deps }) => { - // Simulate spawning a process that's still running - deps.spawn('echo', {}); - // Then trigger SIGINT - await handlers.SIGINT(); - return { buildId: null, screenshotsCaptured: 0 }; - }, - detectBranch: async () => 'main', - detectCommit: async () => 'abc', - detectCommitMessage: async () => 'test', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async () => 'build', - spawn: () => ({ - killed: false, - kill: signal => { - processKilled = true; - assert.strictEqual(signal, 'SIGKILL'); - }, - }), - output, - exit: () => {}, - processOn: (event, handler) => { - handlers[event] = handler; - }, - processRemoveListener: () => {}, - } - ); - - assert.strictEqual(processKilled, true); - }); - - it('cleanup finalizes build when buildId exists', async () => { - let output = createMockOutput(); - let handlers = {}; - let finalizeCalled = false; - - await runCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - createUploader: () => ({}), - runTests: async ({ deps }) => { - // Trigger onBuildCreated to set buildId - deps.onBuildCreated({ buildId: 'build-cleanup-test' }); - // Then trigger SIGINT - await handlers.SIGINT(); - return { buildId: 'build-cleanup-test', screenshotsCaptured: 0 }; - }, - finalizeBuild: async () => { - finalizeCalled = true; - }, - detectBranch: async () => 'main', - detectCommit: async () => 'abc', - detectCommitMessage: async () => 'test', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async () => 'build', - output, - exit: () => {}, - processOn: (event, handler) => { - handlers[event] = handler; - }, - processRemoveListener: () => {}, - } - ); - - assert.strictEqual(finalizeCalled, true); - }); - - it('cleanup handles finalizeBuild error silently', async () => { - let output = createMockOutput(); - let handlers = {}; - - // Should not throw even when finalizeBuild fails - await runCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - createUploader: () => ({}), - runTests: async ({ deps }) => { - deps.onBuildCreated({ buildId: 'build-error-test' }); - await handlers.SIGINT(); - return { buildId: 'build-error-test', screenshotsCaptured: 0 }; - }, - finalizeBuild: async () => { - throw new Error('Finalize failed'); - }, - detectBranch: async () => 'main', - detectCommit: async () => 'abc', - detectCommitMessage: async () => 'test', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async () => 'build', - output, - exit: () => {}, - processOn: (event, handler) => { - handlers[event] = handler; - }, - processRemoveListener: () => {}, - } - ); - - // Test passes if no error was thrown - assert.ok(true); - }); - - it('cleanup handles serverManager.stop error silently', async () => { - let output = createMockOutput(); - let handlers = {}; - - await runCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => { - throw new Error('Stop failed'); - }, - }), - createUploader: () => ({}), - runTests: async () => { - await handlers.SIGINT(); - return { buildId: null, screenshotsCaptured: 0 }; - }, - detectBranch: async () => 'main', - detectCommit: async () => 'abc', - detectCommitMessage: async () => 'test', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async () => 'build', - output, - exit: () => {}, - processOn: (event, handler) => { - handlers[event] = handler; - }, - processRemoveListener: () => {}, - } - ); - - // Test passes if no error was thrown - assert.ok(true); - }); - - it('buildManager.createBuild delegates to createBuildObject', async () => { - let output = createMockOutput(); - let createBuildCalled = false; - let capturedOptions = null; - - await runCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - createBuildObject: opts => { - createBuildCalled = true; - capturedOptions = opts; - return { id: 'build-123', ...opts }; - }, - createUploader: () => ({}), - runTests: async ({ deps }) => { - // Call buildManager.createBuild to exercise line 212 - let build = await deps.buildManager.createBuild({ - name: 'test-build', - }); - assert.strictEqual(build.name, 'test-build'); - return { buildId: null, screenshotsCaptured: 0 }; - }, - detectBranch: async () => 'main', - detectCommit: async () => 'abc', - detectCommitMessage: async () => 'test', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async () => 'build', - output, - exit: () => {}, - processOn: () => {}, - processRemoveListener: () => {}, - } - ); - - assert.strictEqual(createBuildCalled, true); - assert.strictEqual(capturedOptions.name, 'test-build'); - }); - }); - describe('validateRunOptions', () => { - describe('test command validation', () => { - it('should pass with valid test command', () => { - let errors = validateRunOptions('pnpm test', {}); - assert.strictEqual(errors.length, 0); - }); - - it('should fail with empty test command', () => { - let errors = validateRunOptions('', {}); - assert.ok(errors.includes('Test command is required')); - }); - - it('should fail with null test command', () => { - let errors = validateRunOptions(null, {}); - assert.ok(errors.includes('Test command is required')); - }); - - it('should fail with whitespace-only test command', () => { - let errors = validateRunOptions(' ', {}); - assert.ok(errors.includes('Test command is required')); - }); - }); - - describe('port validation', () => { - it('should pass with valid port', () => { - let errors = validateRunOptions('pnpm test', { port: '3000' }); - assert.strictEqual(errors.length, 0); - }); - - it('should fail with invalid port number', () => { - let errors = validateRunOptions('pnpm test', { port: 'invalid' }); - assert.ok( - errors.includes('Port must be a valid number between 1 and 65535') - ); - }); - - it('should fail with decimal port number', () => { - let errors = validateRunOptions('pnpm test', { port: '3000.5' }); - assert.ok( - errors.includes('Port must be a valid number between 1 and 65535') - ); - }); - - it('should fail with port out of range (too low)', () => { - let errors = validateRunOptions('pnpm test', { port: '0' }); - assert.ok( - errors.includes('Port must be a valid number between 1 and 65535') - ); - }); - - it('should fail with port out of range (too high)', () => { - let errors = validateRunOptions('pnpm test', { port: '65536' }); - assert.ok( - errors.includes('Port must be a valid number between 1 and 65535') - ); - }); - }); - - describe('timeout validation', () => { - it('should pass with valid timeout', () => { - let errors = validateRunOptions('pnpm test', { timeout: '5000' }); - assert.strictEqual(errors.length, 0); - }); - - it('should fail with invalid timeout', () => { - let errors = validateRunOptions('pnpm test', { timeout: 'invalid' }); - assert.ok( - errors.includes('Timeout must be at least 1000 milliseconds') - ); - }); - - it('should fail with decimal timeout', () => { - let errors = validateRunOptions('pnpm test', { timeout: '5000.5' }); - assert.ok( - errors.includes('Timeout must be at least 1000 milliseconds') - ); + it('accepts a valid command and run configuration', () => { + let errors = validateRunOptions('pnpm test', { + port: '3000', + timeout: '5000', + batchSize: '10', + uploadTimeout: '30000', + threshold: '0', + minClusterSize: '1', }); - it('should fail with timeout too low', () => { - let errors = validateRunOptions('pnpm test', { timeout: '500' }); - assert.ok( - errors.includes('Timeout must be at least 1000 milliseconds') - ); - }); + assert.deepStrictEqual(errors, []); }); - describe('batch size validation', () => { - it('should pass with valid batch size', () => { - let errors = validateRunOptions('pnpm test', { batchSize: '10' }); - assert.strictEqual(errors.length, 0); - }); - - it('should fail with invalid batch size', () => { - let errors = validateRunOptions('pnpm test', { batchSize: 'invalid' }); - assert.ok(errors.includes('Batch size must be a positive integer')); - }); - - it('should fail with decimal batch size', () => { - let errors = validateRunOptions('pnpm test', { batchSize: '2.5' }); - assert.ok(errors.includes('Batch size must be a positive integer')); + it('reports invalid command, port, and timeout values', () => { + let errors = validateRunOptions('', { + port: 'invalid', + timeout: '500', }); - it('should fail with zero batch size', () => { - let errors = validateRunOptions('pnpm test', { batchSize: '0' }); - assert.ok(errors.includes('Batch size must be a positive integer')); - }); - - it('should fail with negative batch size', () => { - let errors = validateRunOptions('pnpm test', { batchSize: '-5' }); - assert.ok(errors.includes('Batch size must be a positive integer')); - }); + assert.deepStrictEqual(errors, [ + 'Test command is required', + 'Port must be a valid number between 1 and 65535', + 'Timeout must be at least 1000 milliseconds', + ]); }); - describe('upload timeout validation', () => { - it('should pass with valid upload timeout', () => { - let errors = validateRunOptions('pnpm test', { - uploadTimeout: '30000', - }); - assert.strictEqual(errors.length, 0); - }); - - it('should fail with invalid upload timeout', () => { - let errors = validateRunOptions('pnpm test', { - uploadTimeout: 'invalid', - }); - assert.ok( - errors.includes( - 'Upload timeout must be a positive integer (milliseconds)' - ) - ); - }); - - it('should fail with decimal upload timeout', () => { - let errors = validateRunOptions('pnpm test', { - uploadTimeout: '2500.5', - }); - assert.ok( - errors.includes( - 'Upload timeout must be a positive integer (milliseconds)' - ) - ); + it('rejects non-integer batch and upload timeout values', () => { + let errors = validateRunOptions('pnpm test', { + batchSize: '2.5', + uploadTimeout: '0', }); - it('should fail with zero upload timeout', () => { - let errors = validateRunOptions('pnpm test', { uploadTimeout: '0' }); - assert.ok( - errors.includes( - 'Upload timeout must be a positive integer (milliseconds)' - ) - ); - }); + assert.deepStrictEqual(errors, [ + 'Batch size must be a positive integer', + 'Upload timeout must be a positive integer (milliseconds)', + ]); }); - describe('comparison validation', () => { - it('should pass with exact-match threshold and min cluster size', () => { - let errors = validateRunOptions('pnpm test', { - threshold: '0', - minClusterSize: '1', - }); - - assert.strictEqual(errors.length, 0); - }); - - it('should fail with invalid threshold', () => { - let errors = validateRunOptions('pnpm test', { - threshold: 'invalid', - }); - - assert.ok( - errors.includes( - 'Threshold must be a non-negative number (CIEDE2000 Delta E)' - ) - ); + it('rejects invalid comparison thresholds and cluster sizes', () => { + let errors = validateRunOptions('pnpm test', { + threshold: '2abc', + minClusterSize: '2.5', }); - it('should fail when threshold has trailing text', () => { - let errors = validateRunOptions('pnpm test', { - threshold: '2abc', - }); - - assert.ok( - errors.includes( - 'Threshold must be a non-negative number (CIEDE2000 Delta E)' - ) - ); - }); - - it('should fail with non-integer min cluster size', () => { - let errors = validateRunOptions('pnpm test', { - minClusterSize: '2.5', - }); - - assert.ok( - errors.includes('Min cluster size must be a positive integer') - ); - }); - - it('should fail with zero min cluster size', () => { - let errors = validateRunOptions('pnpm test', { - minClusterSize: '0', - }); - - assert.ok( - errors.includes('Min cluster size must be a positive integer') - ); - }); - }); - - describe('multiple validation errors', () => { - it('should return all validation errors', () => { - let errors = validateRunOptions('', { - port: 'invalid', - timeout: '500', - batchSize: '-1', - }); - - assert.strictEqual(errors.length, 4); - assert.ok(errors.includes('Test command is required')); - assert.ok( - errors.includes('Port must be a valid number between 1 and 65535') - ); - assert.ok( - errors.includes('Timeout must be at least 1000 milliseconds') - ); - assert.ok(errors.includes('Batch size must be a positive integer')); - }); + assert.deepStrictEqual(errors, [ + 'Threshold must be a non-negative number (CIEDE2000 Delta E)', + 'Min cluster size must be a positive integer', + ]); }); }); }); diff --git a/tests/commands/status.test.js b/tests/commands/status.test.js index c99b13c7..a050bfb8 100644 --- a/tests/commands/status.test.js +++ b/tests/commands/status.test.js @@ -11,50 +11,9 @@ import { getProcessingStatus, normalizeBuildStatus, shouldFailStatus, - statusCommand, validateStatusOptions, } from '../../src/commands/status.js'; -/** - * Create mock output object that tracks calls - */ -function createMockOutput() { - let calls = []; - return { - calls, - configure: opts => calls.push({ method: 'configure', args: [opts] }), - info: msg => calls.push({ method: 'info', args: [msg] }), - debug: (msg, data) => calls.push({ method: 'debug', args: [msg, data] }), - error: (msg, err) => calls.push({ method: 'error', args: [msg, err] }), - warn: msg => calls.push({ method: 'warn', args: [msg] }), - success: msg => calls.push({ method: 'success', args: [msg] }), - data: d => calls.push({ method: 'data', args: [d] }), - startSpinner: msg => calls.push({ method: 'startSpinner', args: [msg] }), - stopSpinner: () => calls.push({ method: 'stopSpinner', args: [] }), - cleanup: () => calls.push({ method: 'cleanup', args: [] }), - header: (cmd, mode) => calls.push({ method: 'header', args: [cmd, mode] }), - keyValue: (data, opts) => - calls.push({ method: 'keyValue', args: [data, opts] }), - labelValue: (label, value, opts) => - calls.push({ method: 'labelValue', args: [label, value, opts] }), - blank: () => calls.push({ method: 'blank', args: [] }), - hint: msg => calls.push({ method: 'hint', args: [msg] }), - divider: () => calls.push({ method: 'divider', args: [] }), - print: msg => calls.push({ method: 'print', args: [msg] }), - progressBar: () => '████████', - link: (_label, url) => url, - getColors: () => ({ - brand: { - success: s => s, - danger: s => s, - warning: s => s, - info: s => s, - textMuted: s => s, - }, - }), - }; -} - function createBuild(overrides = {}) { return { id: 'build-123', @@ -83,24 +42,6 @@ function createBuild(overrides = {}) { } function createStatusBundle(overrides = {}) { - let build = { - id: 'build-123', - status: 'completed', - name: 'Homepage', - created_at: '2026-05-18T12:00:00.000Z', - updated_at: '2026-05-18T12:01:00.000Z', - completed_at: '2026-05-18T12:02:00.000Z', - environment: 'ci', - branch: 'main', - commit_sha: 'abcdef1234567890', - commit_message: 'Update homepage', - visual_review: { state: 'pending' }, - execution_time_ms: 4250, - is_baseline: false, - user_agent: 'vizzly-test', - ...overrides.build, - }; - return { resource: 'build_status', schema_version: 1, @@ -122,492 +63,180 @@ function createStatusBundle(overrides = {}) { }, links: { web: 'https://app.test/acme/web/builds/build-123' }, ...overrides, - build, - }; -} - -function createStatusHarness( - status = createStatusBundle(), - previewInfo = null -) { - let output = createMockOutput(); - let clientConfig = null; - let exitCode = null; - - return { - output, - get clientConfig() { - return clientConfig; - }, - get exitCode() { - return exitCode; - }, - deps: { - loadConfig: async () => ({ - apiKey: 'test-token', - apiUrl: 'https://api.test/api', - }), - createApiClient: config => { - clientConfig = config; - return { kind: 'client' }; - }, - getBuildStatus: async () => - status.resource === 'build_status' ? status : { build: status }, - getPreviewInfo: async () => previewInfo, - getApiUrl: () => 'https://app.test/api', - output, - exit: code => { - exitCode = code; - }, + build: { + id: 'build-123', + status: 'completed', + name: 'Homepage', + created_at: '2026-05-18T12:00:00.000Z', + updated_at: '2026-05-18T12:01:00.000Z', + completed_at: '2026-05-18T12:02:00.000Z', + environment: 'ci', + branch: 'main', + commit_sha: 'abcdef1234567890', + commit_message: 'Update homepage', + visual_review: { state: 'pending' }, + execution_time_ms: 4250, + is_baseline: false, + user_agent: 'vizzly-test', + ...overrides.build, }, }; } -describe('commands/status', () => { - describe('validateStatusOptions', () => { - it('returns no errors for valid build ID', () => { - let errors = validateStatusOptions('build-123'); - assert.deepStrictEqual(errors, []); - }); - - it('returns error for empty build ID', () => { - let errors = validateStatusOptions(''); - assert.deepStrictEqual(errors, ['Build ID is required']); - }); - - it('returns error for whitespace build ID', () => { - let errors = validateStatusOptions(' '); - assert.deepStrictEqual(errors, ['Build ID is required']); - }); +describe('validateStatusOptions', () => { + it('accepts a build ID', () => { + assert.deepStrictEqual(validateStatusOptions('build-123'), []); }); - describe('status helpers', () => { - it('normalizes wrapped and unwrapped build responses', () => { - let build = createBuild(); - - assert.strictEqual(normalizeBuildStatus({ build }), build); - assert.strictEqual(normalizeBuildStatus(build), build); - }); - - it('creates JSON status data with preview details', () => { - let data = createStatusData(createStatusBundle(), { - preview_url: 'https://preview.test', - status: 'ready', - file_count: 12, - expires_at: '2026-05-19T12:00:00.000Z', - }); - - assert.deepStrictEqual(data, { - resource: 'build_status', - schemaVersion: 1, - buildId: 'build-123', - status: 'completed', - conclusion: 'review_required', - name: 'Homepage', - createdAt: '2026-05-18T12:00:00.000Z', - updatedAt: '2026-05-18T12:01:00.000Z', - completedAt: '2026-05-18T12:02:00.000Z', - environment: 'ci', - branch: 'main', - commit: 'abcdef1234567890', - commitMessage: 'Update homepage', - screenshotsTotal: 3, - processing: { - total: 3, - completed: 3, - failed: 0, - active: 0, - pending: 0, - }, - comparisonsTotal: 3, - comparisons: { total: 3, new: 1, changed: 1, identical: 1 }, - newComparisons: 1, - changedComparisons: 1, - identicalComparisons: 1, - reviewState: 'pending', - review: { - pending: 2, - approved: 1, - rejected: 0, - auto_approved: 0, - }, - reviewFlow: 'cricket', - visualReview: { state: 'pending' }, - approvalStatus: undefined, - executionTime: 4250, - isBaseline: false, - userAgent: 'vizzly-test', - scope: { - organization: { id: 'org-1', slug: 'acme' }, - project: { id: 'project-123', slug: 'web' }, - }, - links: { web: 'https://app.test/acme/web/builds/build-123' }, - suggestedCommands: [ - { - label: 'Inspect build context', - command: - 'vizzly --json context build build-123 --agent --source cloud', - }, - { - label: 'List comparisons', - command: 'vizzly --json comparisons --build build-123', - }, - ], - preview: { - url: 'https://preview.test', - status: 'ready', - fileCount: 12, - expiresAt: '2026-05-19T12:00:00.000Z', - }, - }); - }); - - it('keeps missing legacy status facts unknown instead of inventing zeroes', () => { - let data = createStatusData({ - build: { - id: 'legacy-build', - status: 'processing', - approval_status: 'pending', - pending_screenshots: 12, - }, - }); - - assert.strictEqual(data.reviewState, 'pending'); - assert.strictEqual(data.processing, undefined); - assert.strictEqual(data.screenshotsTotal, undefined); - assert.strictEqual(data.comparisons, undefined); - assert.strictEqual(data.comparisonsTotal, undefined); - assert.strictEqual(data.newComparisons, undefined); - }); - - it('reads canonical and legacy review facts without mixing them into processing', () => { - let canonical = createStatusBundle(); - let legacy = createBuild({ - completed_jobs: 2, - processing_screenshots: 1, - pending_screenshots: 9, - }); - - assert.strictEqual(getBuildReviewState(canonical), 'pending'); - assert.deepStrictEqual(getProcessingStatus(legacy), { - total: 3, - completed: 2, - failed: 0, - active: 1, - }); - assert.deepStrictEqual(getComparisonStatus(canonical), { - total: 3, - new: 1, - changed: 1, - identical: 1, - }); - }); - - it('creates human build info and comparison stats', () => { - let build = createBuild(); - let colors = createMockOutput().getColors(); - - assert.deepStrictEqual(createBuildInfo(build), { - Name: 'Homepage', - Status: 'COMPLETED', - Environment: 'ci', - Branch: 'main', - Commit: 'abcdef12 - Update homepage', - }); - assert.strictEqual( - createComparisonStats(build, colors), - '1 new · 1 changed · 1 identical' - ); - }); - - it('prefers slug build URLs and retains the legacy project route', () => { - assert.strictEqual( - createBuildUrl('https://app.test/api', createBuild(), { - organization: { slug: 'acme' }, - project: { slug: 'web' }, - }), - 'https://app.test/acme/web/builds/build-123' - ); - assert.strictEqual( - createBuildUrl('https://app.test/api', createBuild()), - 'https://app.test/projects/project-123/builds/build-123' - ); - assert.strictEqual( - createBuildUrl('https://api.test/api/v1', createBuild()), - 'https://api.test/projects/project-123/builds/build-123' - ); - assert.strictEqual(createBuildUrl(null, createBuild()), null); - }); - - it('uses API processing conclusions without changing review failures', () => { - assert.strictEqual( - shouldFailStatus(createBuild({ status: 'failed' })), - true - ); - assert.strictEqual( - shouldFailStatus(createBuild({ failed_jobs: 1 })), - true - ); - assert.strictEqual(shouldFailStatus(createBuild()), false); - assert.strictEqual( - shouldFailStatus( - createStatusBundle({ conclusion: 'processing_failed' }) - ), - true - ); - assert.strictEqual( - shouldFailStatus(createStatusBundle({ conclusion: 'rejected' })), - false - ); - }); - - it('creates executable visual-context follow-up commands', () => { - assert.deepStrictEqual(createStatusSuggestedCommands(createBuild()), [ - { - label: 'Inspect build context', - command: - 'vizzly --json context build build-123 --agent --source cloud', - }, - { - label: 'List comparisons', - command: 'vizzly --json comparisons --build build-123', - }, - ]); - }); + it('rejects missing and whitespace-only build IDs', () => { + assert.deepStrictEqual(validateStatusOptions(''), ['Build ID is required']); + assert.deepStrictEqual(validateStatusOptions(' '), [ + 'Build ID is required', + ]); }); +}); - describe('statusCommand', () => { - it('fetches build and preview data for JSON output', async () => { - let harness = createStatusHarness(createBuild(), { - preview_url: 'https://preview.test', - status: 'ready', - file_count: 4, - expires_at: '2026-05-19T12:00:00.000Z', - }); - - await statusCommand('build-123', {}, { json: true }, harness.deps); +describe('status data', () => { + it('normalizes wrapped and unwrapped build responses', () => { + let build = createBuild(); - assert.deepStrictEqual(harness.clientConfig, { - baseUrl: 'https://api.test/api', - token: 'test-token', - command: 'status', - }); + assert.strictEqual(normalizeBuildStatus({ build }), build); + assert.strictEqual(normalizeBuildStatus(build), build); + }); - let dataCall = harness.output.calls.find(call => call.method === 'data'); - assert.strictEqual(dataCall.args[0].buildId, 'build-123'); - assert.deepStrictEqual(dataCall.args[0].preview, { - url: 'https://preview.test', - status: 'ready', - fileCount: 4, - expiresAt: '2026-05-19T12:00:00.000Z', - }); - assert.ok(harness.output.calls.some(call => call.method === 'cleanup')); + it('preserves canonical processing, review, and preview facts in JSON data', () => { + let data = createStatusData(createStatusBundle(), { + preview_url: 'https://preview.test', + status: 'ready', + file_count: 12, + expires_at: '2026-05-19T12:00:00.000Z', }); - it('prints human-readable status and exits non-zero for failed builds', async () => { - let harness = createStatusHarness( - createBuild({ status: 'failed', failed_jobs: 1 }), - { preview_url: 'https://preview.test' } - ); - - await statusCommand('build-123', {}, {}, harness.deps); - - assert.strictEqual(harness.exitCode, 1); - assert.ok( - harness.output.calls.some( - call => call.method === 'header' && call.args[1] === 'failed' - ) - ); - assert.ok( - harness.output.calls.some( - call => call.method === 'labelValue' && call.args[0] === 'Preview' - ) - ); + assert.strictEqual(data.resource, 'build_status'); + assert.strictEqual(data.buildId, 'build-123'); + assert.strictEqual(data.reviewState, 'pending'); + assert.deepStrictEqual(data.processing, { + total: 3, + completed: 3, + failed: 0, + active: 0, + pending: 0, }); - - it('prints API processing counts without inventing a progress percentage', async () => { - let harness = createStatusHarness( - createStatusBundle({ - build: { status: 'processing', completed_at: null }, - conclusion: 'processing', - processing: { - total: 4, - completed: 2, - failed: 0, - active: 2, - pending: 0, - }, - }) - ); - - await statusCommand('build-123', {}, {}, harness.deps); - - assert.ok( - harness.output.calls.some( - call => - call.method === 'labelValue' && - call.args[0] === 'Processing' && - call.args[1] === - '4 total · 2 completed · 0 failed · 2 active · 0 pending' - ) - ); - assert.ok( - harness.output.calls.every( - call => call.method !== 'print' || !call.args[0].includes('%') - ) - ); - assert.ok( - harness.output.calls.some( - call => - call.method === 'labelValue' && - call.args[0] === 'Review' && - call.args[1] === 'pending' - ) - ); - assert.ok( - harness.output.calls.some( - call => - call.method === 'labelValue' && - call.args[0] === 'View' && - call.args[1] === 'https://app.test/acme/web/builds/build-123' - ) - ); - assert.ok( - harness.output.calls.some( - call => - call.method === 'print' && - call.args[0].includes( - 'vizzly --json context build build-123 --agent --source cloud' - ) - ) - ); + assert.deepStrictEqual(data.preview, { + url: 'https://preview.test', + status: 'ready', + fileCount: 12, + expiresAt: '2026-05-19T12:00:00.000Z', }); + }); - it('does not render NaN average diff in verbose output', async () => { - let harness = createStatusHarness( - createBuild({ avg_diff_percentage: undefined }) - ); - - await statusCommand('build-123', {}, { verbose: true }, harness.deps); - - let keyValueCalls = harness.output.calls.filter( - call => call.method === 'keyValue' - ); - let verboseInfo = keyValueCalls.at(-1).args[0]; - assert.equal(Object.hasOwn(verboseInfo, 'Avg Diff'), false); + it('does not invent missing legacy status facts', () => { + let data = createStatusData({ + build: { + id: 'legacy-build', + status: 'processing', + approval_status: 'pending', + pending_screenshots: 12, + }, }); - it('cleans up and exits when no API token is configured', async () => { - let output = createMockOutput(); - let exitCode = null; - - let result = await statusCommand( - 'build-123', - {}, - {}, - { - loadConfig: async () => ({ apiUrl: 'https://api.test' }), - output, - exit: code => { - exitCode = code; - }, - } - ); + assert.strictEqual(data.reviewState, 'pending'); + assert.strictEqual(data.processing, undefined); + assert.strictEqual(data.screenshotsTotal, undefined); + assert.strictEqual(data.comparisons, undefined); + }); - assert.strictEqual(exitCode, 1); - assert.deepStrictEqual(result, { - success: false, - result: { reason: 'missing_token' }, - }); - assert.ok(output.calls.some(call => call.method === 'cleanup')); + it('keeps processing facts separate from review facts', () => { + let canonical = createStatusBundle(); + let legacy = createBuild({ + completed_jobs: 2, + processing_screenshots: 1, + pending_screenshots: 9, }); - it('uses logged-in user auth when no project token is configured', async () => { - let harness = createStatusHarness(); - harness.deps.loadConfig = async () => ({ - userToken: 'user-token', - apiUrl: 'https://api.test', - }); - - await statusCommand('build-123', {}, { json: true }, harness.deps); - - assert.strictEqual(harness.clientConfig.token, 'user-token'); - assert.strictEqual(harness.exitCode, null); + assert.strictEqual(getBuildReviewState(canonical), 'pending'); + assert.deepStrictEqual(getProcessingStatus(legacy), { + total: 3, + completed: 2, + failed: 0, + active: 1, }); - - it('does not fail CI when API returns 5xx error', async () => { - let output = createMockOutput(); - let exitCode = null; - - let apiError = new Error( - 'API request failed: 500 - Internal Server Error' - ); - apiError.context = { status: 500 }; - - let result = await statusCommand( - 'build-123', - {}, - {}, - { - loadConfig: async () => ({ - apiKey: 'test-token', - apiUrl: 'https://api.test', - }), - createApiClient: () => ({}), - getBuildStatus: async () => { - throw apiError; - }, - getPreviewInfo: async () => null, - getApiUrl: () => 'https://api.test', - output, - exit: code => { - exitCode = code; - }, - } - ); - - assert.strictEqual(exitCode, null); - assert.strictEqual(result.success, true); - assert.strictEqual(result.result.skipped, true); - assert.ok( - output.calls.some( - c => c.method === 'warn' && c.args[0].includes('API unavailable') - ) - ); + assert.deepStrictEqual(getComparisonStatus(canonical), { + total: 3, + new: 1, + changed: 1, + identical: 1, }); + }); +}); - it('still fails for 4xx client errors', async () => { - let output = createMockOutput(); - let exitCode = null; +describe('status display decisions', () => { + it('creates human build info and comparison stats', () => { + let colors = { + brand: { + success: value => value, + danger: value => value, + info: value => value, + warning: value => value, + textMuted: value => value, + }, + }; + + assert.deepStrictEqual(createBuildInfo(createBuild()), { + Name: 'Homepage', + Status: 'COMPLETED', + Environment: 'ci', + Branch: 'main', + Commit: 'abcdef12 - Update homepage', + }); + assert.strictEqual( + createComparisonStats(createBuild(), colors), + '1 new · 1 changed · 1 identical' + ); + }); - let apiError = new Error('API request failed: 404 - Not Found'); - apiError.context = { status: 404 }; + it('prefers scoped build URLs and retains the legacy fallback', () => { + assert.strictEqual( + createBuildUrl('https://app.test/api', createBuild(), { + organization: { slug: 'acme' }, + project: { slug: 'web' }, + }), + 'https://app.test/acme/web/builds/build-123' + ); + assert.strictEqual( + createBuildUrl('https://app.test/api', createBuild()), + 'https://app.test/projects/project-123/builds/build-123' + ); + assert.strictEqual(createBuildUrl(null, createBuild()), null); + }); - await statusCommand( - 'build-123', - {}, - {}, - { - loadConfig: async () => ({ - apiKey: 'test-token', - apiUrl: 'https://api.test', - }), - createApiClient: () => ({}), - getBuildStatus: async () => { - throw apiError; - }, - getPreviewInfo: async () => null, - getApiUrl: () => 'https://api.test', - output, - exit: code => { - exitCode = code; - }, - } - ); + it('fails only for processing failures, not review-required builds', () => { + assert.strictEqual( + shouldFailStatus(createBuild({ status: 'failed' })), + true + ); + assert.strictEqual(shouldFailStatus(createBuild({ failed_jobs: 1 })), true); + assert.strictEqual(shouldFailStatus(createBuild()), false); + assert.strictEqual( + shouldFailStatus(createStatusBundle({ conclusion: 'processing_failed' })), + true + ); + assert.strictEqual( + shouldFailStatus(createStatusBundle({ conclusion: 'rejected' })), + false + ); + }); - assert.strictEqual(exitCode, 1); - assert.ok(output.calls.some(c => c.method === 'error')); - assert.ok(output.calls.some(c => c.method === 'cleanup')); - }); + it('creates executable visual-context follow-up commands', () => { + assert.deepStrictEqual(createStatusSuggestedCommands(createBuild()), [ + { + label: 'Inspect build context', + command: 'vizzly --json context build build-123 --agent --source cloud', + }, + { + label: 'List comparisons', + command: 'vizzly --json comparisons --build build-123', + }, + ]); }); }); diff --git a/tests/commands/tdd.test.js b/tests/commands/tdd.test.js index b2d9d0ba..54bc6611 100644 --- a/tests/commands/tdd.test.js +++ b/tests/commands/tdd.test.js @@ -1,1087 +1,57 @@ import assert from 'node:assert'; import { describe, it } from 'node:test'; -import { tddCommand, validateTddOptions } from '../../src/commands/tdd.js'; - -/** - * Create mock output object that tracks calls - */ -function createMockOutput() { - let calls = []; - return { - calls, - configure: opts => calls.push({ method: 'configure', args: [opts] }), - header: (cmd, mode) => calls.push({ method: 'header', args: [cmd, mode] }), - info: msg => calls.push({ method: 'info', args: [msg] }), - debug: (a, b, c) => calls.push({ method: 'debug', args: [a, b, c] }), - error: (msg, err) => calls.push({ method: 'error', args: [msg, err] }), - warn: msg => calls.push({ method: 'warn', args: [msg] }), - success: msg => calls.push({ method: 'success', args: [msg] }), - result: msg => calls.push({ method: 'result', args: [msg] }), - print: msg => calls.push({ method: 'print', args: [msg] }), - data: obj => calls.push({ method: 'data', args: [obj] }), - startSpinner: msg => calls.push({ method: 'startSpinner', args: [msg] }), - stopSpinner: () => calls.push({ method: 'stopSpinner', args: [] }), - cleanup: () => calls.push({ method: 'cleanup', args: [] }), - }; -} - -/** - * Create minimal mock config - */ -function createMockConfig(overrides = {}) { - return { - apiKey: null, - apiUrl: 'https://api.test', - server: { port: 47392, timeout: 30000 }, - build: { environment: 'test' }, - comparison: { threshold: 0.1, minClusterSize: 2 }, - ...overrides, - }; -} +import { validateTddOptions } from '../../src/commands/tdd.js'; describe('commands/tdd', () => { - describe('tddCommand', () => { - it('works without API token in TDD mode', async () => { - let output = createMockOutput(); - let runTestsCalled = false; - - let { result, cleanup } = await tddCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - runTests: async () => { - runTestsCalled = true; - return { screenshotsCaptured: 5, comparisons: [] }; - }, - detectBranch: async () => 'main', - detectCommit: async () => 'abc123', - output, - } - ); - - assert.strictEqual(result.success, true); - assert.strictEqual(runTestsCalled, true); - await cleanup(); - }); - - it('requires token when baselineBuild flag is set', async () => { - let output = createMockOutput(); - - let { result } = await tddCommand( - 'pnpm test', - { baselineBuild: 'build-123' }, - {}, - { - loadConfig: async () => createMockConfig({ apiKey: null }), - output, - } - ); - - assert.strictEqual(result.success, false); - assert.ok(result.error.includes('API token required')); - }); - - it('requires token when baselineComparison flag is set', async () => { - let output = createMockOutput(); - - let { result } = await tddCommand( - 'pnpm test', - { baselineComparison: 'comp-123' }, - {}, - { - loadConfig: async () => createMockConfig({ apiKey: null }), - output, - } - ); - - assert.strictEqual(result.success, false); - assert.ok(result.error.includes('API token required')); - }); - - it('configures output with global options', async () => { - let output = createMockOutput(); - - await tddCommand( - 'pnpm test', - {}, - { json: true, verbose: true, noColor: true }, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - runTests: async () => ({ screenshotsCaptured: 0, comparisons: [] }), - detectBranch: async () => 'main', - detectCommit: async () => 'abc', - output, - } - ); - - let configureCall = output.calls.find(c => c.method === 'configure'); - assert.ok(configureCall); - assert.strictEqual(configureCall.args[0].json, true); - assert.strictEqual(configureCall.args[0].verbose, true); - assert.strictEqual(configureCall.args[0].color, false); - }); - - it('shows header in non-daemon mode', async () => { - let output = createMockOutput(); - - await tddCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - runTests: async () => ({ screenshotsCaptured: 0, comparisons: [] }), - detectBranch: async () => 'main', - detectCommit: async () => 'abc', - output, - } - ); - - assert.ok(output.calls.some(c => c.method === 'header')); - }); - - it('skips header in daemon mode', async () => { - let output = createMockOutput(); - - await tddCommand( - 'pnpm test', - { daemon: true }, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - initializeDaemon: async () => {}, - detectBranch: async () => 'main', - detectCommit: async () => 'abc', - output, - } - ); - - assert.ok(!output.calls.some(c => c.method === 'header')); - }); - - it('returns daemon result in daemon mode', async () => { - let output = createMockOutput(); - - let { result, cleanup } = await tddCommand( - 'pnpm test', - { daemon: true }, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - initializeDaemon: async () => {}, - detectBranch: async () => 'main', - detectCommit: async () => 'abc', - output, - } - ); - - assert.strictEqual(result.success, true); - assert.strictEqual(result.daemon, true); - assert.strictEqual(result.port, 47392); - await cleanup(); - }); - - it('handles successful test run with passed comparisons', async () => { - let output = createMockOutput(); - - let { result } = await tddCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - runTests: async () => ({ - screenshotsCaptured: 3, - comparisons: [ - { status: 'passed' }, - { status: 'passed' }, - { status: 'passed' }, - ], - }), - detectBranch: async () => 'main', - detectCommit: async () => 'abc', - output, - } - ); - - assert.strictEqual(result.success, true); - assert.strictEqual(result.exitCode, 0); - let contextCall = output.calls.find( - call => - call.method === 'print' && - call.args[0].includes( - 'vizzly context build current --source local --agent' - ) - ); - assert.ok(contextCall); - assert.doesNotMatch(contextCall.args[0], /--json/); - }); - - it('handles test run with failed comparisons', async () => { - let output = createMockOutput(); - - let { result } = await tddCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - runTests: async () => ({ - screenshotsCaptured: 3, - comparisons: [ - { status: 'passed' }, - { status: 'failed' }, - { status: 'failed' }, - ], - }), - detectBranch: async () => 'main', - detectCommit: async () => 'abc', - output, - } - ); - - assert.strictEqual(result.success, false); - assert.strictEqual(result.exitCode, 1); - // Summary output is handled by printResults() in tdd-service.js - }); - - it('prints failed JSON when TDD comparisons fail', async () => { - let output = createMockOutput(); - - let { result } = await tddCommand( - 'pnpm test', - {}, - { json: true }, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - runTests: async () => ({ - screenshotsCaptured: 1, - comparisons: [ - { - name: 'button-primary', - status: 'failed', - signature: 'button-primary|1920|chromium', - diffPercentage: 4.2, - }, - ], - }), - detectBranch: async () => 'main', - detectCommit: async () => 'abc', - output, - } - ); - - let dataCall = output.calls.find(call => call.method === 'data'); - - assert.strictEqual(result.success, false); - assert.strictEqual(result.exitCode, 1); - assert.strictEqual(dataCall.args[0].status, 'failed'); - assert.strictEqual(dataCall.args[0].exitCode, 1); - assert.strictEqual(dataCall.args[0].summary.failed, 1); - }); - - it('handles config loading error', async () => { - let output = createMockOutput(); - - let { result } = await tddCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => { - throw new Error('Config not found'); - }, - output, - } - ); - - assert.strictEqual(result.success, false); - assert.strictEqual(result.exitCode, 1); - assert.ok(result.error.includes('Config not found')); - }); - - it('handles runTests error', async () => { - let output = createMockOutput(); - - let { result } = await tddCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - runTests: async () => { - throw new Error('Test execution failed'); - }, - detectBranch: async () => 'main', - detectCommit: async () => 'abc', - output, - } - ); - - assert.strictEqual(result.success, false); - assert.strictEqual(result.exitCode, 1); - }); - - it('cleanup function stops server', async () => { - let output = createMockOutput(); - let serverStopped = false; - - let { cleanup } = await tddCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => { - serverStopped = true; - }, - }), - runTests: async () => ({ screenshotsCaptured: 0, comparisons: [] }), - detectBranch: async () => 'main', - detectCommit: async () => 'abc', - output, - } - ); - - await cleanup(); - assert.strictEqual(serverStopped, true); - }); - - it('cleanup is idempotent', async () => { - let output = createMockOutput(); - let stopCount = 0; - - let { cleanup } = await tddCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => { - stopCount++; - }, - }), - runTests: async () => ({ screenshotsCaptured: 0, comparisons: [] }), - detectBranch: async () => 'main', - detectCommit: async () => 'abc', - output, - } - ); - - await cleanup(); - await cleanup(); - await cleanup(); - - assert.strictEqual(stopCount, 1); - }); - - it('uses provided git metadata options', async () => { - let output = createMockOutput(); - let capturedRunOptions = null; - - await tddCommand( - 'pnpm test', - { branch: 'feature', commit: 'def456' }, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - runTests: async ({ runOptions }) => { - capturedRunOptions = runOptions; - return { screenshotsCaptured: 0, comparisons: [] }; - }, - detectBranch: async branch => branch || 'default', - detectCommit: async commit => commit || 'default', - output, - } - ); - - assert.strictEqual(capturedRunOptions.branch, 'feature'); - assert.strictEqual(capturedRunOptions.commit, 'def456'); - }); - - it('falls back to build metadata from config', async () => { - let output = createMockOutput(); - let capturedBranchArg = null; - let capturedCommitArg = null; - let capturedRunOptions = null; - - await tddCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => - createMockConfig({ - build: { - branch: 'config-branch', - commit: 'config-commit', - environment: 'test', - }, - }), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - runTests: async ({ runOptions }) => { - capturedRunOptions = runOptions; - return { screenshotsCaptured: 0, comparisons: [] }; - }, - detectBranch: async branch => { - capturedBranchArg = branch; - return branch; - }, - detectCommit: async commit => { - capturedCommitArg = commit; - return commit; - }, - output, - } - ); - - assert.strictEqual(capturedBranchArg, 'config-branch'); - assert.strictEqual(capturedCommitArg, 'config-commit'); - assert.strictEqual(capturedRunOptions.branch, 'config-branch'); - assert.strictEqual(capturedRunOptions.commit, 'config-commit'); - }); - - it('passes build metadata and parallel id through to the TDD run', async () => { - let output = createMockOutput(); - let capturedRunOptions = null; - - await tddCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => - createMockConfig({ - build: { - name: 'Local TDD Build', - environment: 'staging', - message: 'Config message', - }, - comparison: { threshold: 2.5, minClusterSize: 7 }, - parallelId: 'local-parallel', - }), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - runTests: async ({ runOptions }) => { - capturedRunOptions = runOptions; - return { screenshotsCaptured: 0, comparisons: [] }; - }, - detectBranch: async () => 'main', - detectCommit: async () => 'abc123', - detectPullRequestNumber: () => 42, - output, - } - ); - - assert.strictEqual(capturedRunOptions.name, 'Local TDD Build'); - assert.strictEqual(capturedRunOptions.buildName, 'Local TDD Build'); - assert.strictEqual(capturedRunOptions.message, 'Config message'); - assert.strictEqual(capturedRunOptions.environment, 'staging'); - assert.strictEqual(capturedRunOptions.pullRequestNumber, 42); - assert.strictEqual(capturedRunOptions.parallelId, 'local-parallel'); - assert.deepStrictEqual(capturedRunOptions.metadata, { - comparison: { - threshold: 2.5, - minClusterSize: 7, - }, - }); - }); - - it('derives build names when config still has the default placeholder', async () => { - let output = createMockOutput(); - let capturedRunOptions = null; - let capturedBuildNameOverride = 'not-called'; - - await tddCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - runTests: async ({ runOptions }) => { - capturedRunOptions = runOptions; - return { screenshotsCaptured: 0, comparisons: [] }; - }, - detectBranch: async () => 'feature/default-name', - detectCommit: async () => 'abc123', - detectCommitMessage: async () => 'Detected commit message', - detectPullRequestNumber: () => 77, - generateBuildNameWithGit: async override => { - capturedBuildNameOverride = override; - return 'feature-default-name-abc1234'; - }, - output, - } - ); - - assert.strictEqual(capturedBuildNameOverride, undefined); - assert.strictEqual( - capturedRunOptions.buildName, - 'feature-default-name-abc1234' - ); - assert.strictEqual( - capturedRunOptions.name, - 'feature-default-name-abc1234' - ); - assert.strictEqual(capturedRunOptions.message, 'Detected commit message'); - assert.strictEqual(capturedRunOptions.pullRequestNumber, 77); - }); - - it('passes comparison config through to the TDD run', async () => { - let output = createMockOutput(); - let capturedRunOptions = null; - - await tddCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => - createMockConfig({ - comparison: { threshold: 0, minClusterSize: 6 }, - }), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - runTests: async ({ runOptions }) => { - capturedRunOptions = runOptions; - return { screenshotsCaptured: 0, comparisons: [] }; - }, - detectBranch: async () => 'main', - detectCommit: async () => 'abc123', - output, - } - ); - - assert.strictEqual(capturedRunOptions.threshold, 0); - assert.strictEqual(capturedRunOptions.minClusterSize, 6); - }); - - it('passes failOnDiff through to the TDD run', async () => { - let output = createMockOutput(); - let capturedRunOptions = null; - - await tddCommand( - 'pnpm test', - { failOnDiff: true }, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - runTests: async ({ runOptions }) => { - capturedRunOptions = runOptions; - return { screenshotsCaptured: 0, comparisons: [] }; - }, - detectBranch: async () => 'main', - detectCommit: async () => 'abc123', - output, - } - ); - - assert.strictEqual(capturedRunOptions.failOnDiff, true); - }); - - it('invokes onBuildCreated callback', async () => { - let output = createMockOutput(); - - await tddCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - runTests: async ({ deps }) => { - deps.onBuildCreated({ buildId: 'build-1234567890' }); - return { screenshotsCaptured: 1, comparisons: [] }; - }, - detectBranch: async () => 'main', - detectCommit: async () => 'abc', - output, - } - ); - - // debug('build', 'created build-12') - args[0]='build', args[1]='created build-12' - assert.ok( - output.calls.some( - c => - c.method === 'debug' && - c.args[0] === 'build' && - c.args[1]?.includes('build-12') - ) - ); - }); - - it('invokes onServerReady callback', async () => { - let output = createMockOutput(); - - await tddCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - runTests: async ({ deps }) => { - deps.onServerReady({ port: 47392 }); - return { screenshotsCaptured: 0, comparisons: [] }; - }, - detectBranch: async () => 'main', - detectCommit: async () => 'abc', - output, - } - ); - - // debug('server', 'listening on :47392') - assert.ok( - output.calls.some( - c => - c.method === 'debug' && - c.args[0] === 'server' && - c.args[1]?.includes('47392') - ) - ); - }); - - it('invokes onFinalizeFailed callback', async () => { - let output = createMockOutput(); - - await tddCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - runTests: async ({ deps }) => { - deps.onFinalizeFailed({ error: 'Network error' }); - return { screenshotsCaptured: 0, comparisons: [] }; - }, - detectBranch: async () => 'main', - detectCommit: async () => 'abc', - output, - } - ); - - assert.ok( - output.calls.some( - c => c.method === 'warn' && c.args[0].includes('finalize') - ) - ); - }); - - it('invokes onServerReady in daemon mode', async () => { - let output = createMockOutput(); - - await tddCommand( - 'pnpm test', - { daemon: true }, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - initializeDaemon: async ({ deps }) => { - deps.onServerReady({ port: 47392 }); - }, - detectBranch: async () => 'main', - detectCommit: async () => 'abc', - output, - } - ); - - // debug('server', 'listening on :47392') - assert.ok( - output.calls.some( - c => - c.method === 'debug' && - c.args[0] === 'server' && - c.args[1]?.includes('47392') - ) - ); - }); - - // Output pluralization tests removed - summary output is handled by printResults() in tdd-service.js - - it('handles runResult.failed flag', async () => { - let output = createMockOutput(); - - let { result } = await tddCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - runTests: async () => ({ - screenshotsCaptured: 2, - failed: true, - comparisons: [{ status: 'passed' }, { status: 'passed' }], - }), - detectBranch: async () => 'main', - detectCommit: async () => 'abc', - output, - } - ); - - assert.strictEqual(result.success, false); - assert.strictEqual(result.exitCode, 1); - }); - - it('cleanup kills test process if not already killed', async () => { - let output = createMockOutput(); - let processKilled = false; - - let { cleanup } = await tddCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - runTests: async ({ deps }) => { - // Simulate spawning a process - deps.spawn('echo', {}); - return { screenshotsCaptured: 0, comparisons: [] }; - }, - detectBranch: async () => 'main', - detectCommit: async () => 'abc', - spawn: () => ({ - killed: false, - kill: signal => { - processKilled = true; - assert.strictEqual(signal, 'SIGKILL'); - }, - }), - output, - } - ); - - await cleanup(); - assert.strictEqual(processKilled, true); - }); - - it('cleanup does not kill already killed process', async () => { - let output = createMockOutput(); - let killCalled = false; - - let { cleanup } = await tddCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - runTests: async ({ deps }) => { - deps.spawn('echo', {}); - return { screenshotsCaptured: 0, comparisons: [] }; - }, - detectBranch: async () => 'main', - detectCommit: async () => 'abc', - spawn: () => ({ - killed: true, // Already killed - kill: () => { - killCalled = true; - }, - }), - output, - } - ); - - await cleanup(); - assert.strictEqual(killCalled, false); - }); - - it('calls buildManager.createBuild when runTests uses it', async () => { - let output = createMockOutput(); - let createBuildCalled = false; - - await tddCommand( - 'pnpm test', - {}, - {}, - { - loadConfig: async () => createMockConfig(), - createServerManager: () => ({ - start: async () => {}, - stop: async () => {}, - }), - createBuildObject: buildOptions => { - createBuildCalled = true; - return { id: 'test-build', ...buildOptions }; - }, - runTests: async ({ deps }) => { - // Simulate runTests calling buildManager.createBuild - await deps.buildManager.createBuild({ name: 'test' }); - return { screenshotsCaptured: 0, comparisons: [] }; - }, - detectBranch: async () => 'main', - detectCommit: async () => 'abc', - output, - } - ); - - assert.strictEqual(createBuildCalled, true); - }); - }); - describe('validateTddOptions', () => { - describe('test command validation', () => { - it('should pass with valid test command', () => { - let errors = validateTddOptions('pnpm test', {}); - assert.strictEqual(errors.length, 0); - }); - - it('should fail with empty test command', () => { - let errors = validateTddOptions('', {}); - assert.ok(errors.includes('Test command is required')); + it('accepts valid command, port, timeout, and comparison options', () => { + let errors = validateTddOptions('pnpm test', { + port: '3000', + timeout: '5000', + threshold: '2', + minClusterSize: '2', }); - it('should fail with null test command', () => { - let errors = validateTddOptions(null, {}); - assert.ok(errors.includes('Test command is required')); - }); - - it('should fail with whitespace-only test command', () => { - let errors = validateTddOptions(' ', {}); - assert.ok(errors.includes('Test command is required')); - }); - }); - - describe('port validation', () => { - it('should pass with valid port', () => { - let errors = validateTddOptions('pnpm test', { port: '3000' }); - assert.strictEqual(errors.length, 0); - }); - - it('should fail with invalid port number', () => { - let errors = validateTddOptions('pnpm test', { port: 'invalid' }); - assert.ok( - errors.includes('Port must be a valid number between 1 and 65535') - ); - }); - - it('should fail with decimal port number', () => { - let errors = validateTddOptions('pnpm test', { port: '3000.5' }); - assert.ok( - errors.includes('Port must be a valid number between 1 and 65535') - ); - }); - - it('should fail with port out of range (too low)', () => { - let errors = validateTddOptions('pnpm test', { port: '0' }); - assert.ok( - errors.includes('Port must be a valid number between 1 and 65535') - ); - }); - - it('should fail with port out of range (too high)', () => { - let errors = validateTddOptions('pnpm test', { port: '65536' }); - assert.ok( - errors.includes('Port must be a valid number between 1 and 65535') - ); - }); - }); - - describe('timeout validation', () => { - it('should pass with valid timeout', () => { - let errors = validateTddOptions('pnpm test', { timeout: '5000' }); - assert.strictEqual(errors.length, 0); - }); - - it('should fail with invalid timeout', () => { - let errors = validateTddOptions('pnpm test', { timeout: 'invalid' }); - assert.ok( - errors.includes('Timeout must be at least 1000 milliseconds') - ); - }); - - it('should fail with decimal timeout', () => { - let errors = validateTddOptions('pnpm test', { timeout: '5000.5' }); - assert.ok( - errors.includes('Timeout must be at least 1000 milliseconds') - ); - }); - - it('should fail with timeout too low', () => { - let errors = validateTddOptions('pnpm test', { timeout: '500' }); - assert.ok( - errors.includes('Timeout must be at least 1000 milliseconds') - ); - }); + assert.deepStrictEqual(errors, []); }); - describe('threshold validation', () => { - it('should pass with valid threshold', () => { - let errors = validateTddOptions('pnpm test', { threshold: '0.1' }); - assert.strictEqual(errors.length, 0); - }); - - it('should pass with threshold of 0', () => { - let errors = validateTddOptions('pnpm test', { threshold: '0' }); - assert.strictEqual(errors.length, 0); - }); - - it('should pass with threshold of 1', () => { - let errors = validateTddOptions('pnpm test', { threshold: '1' }); - assert.strictEqual(errors.length, 0); - }); - - it('should fail with invalid threshold', () => { - let errors = validateTddOptions('pnpm test', { threshold: 'invalid' }); - assert.ok( - errors.includes( - 'Threshold must be a non-negative number (CIEDE2000 Delta E)' - ) - ); - }); - - it('should fail when threshold has trailing text', () => { - let errors = validateTddOptions('pnpm test', { threshold: '2abc' }); - assert.ok( - errors.includes( - 'Threshold must be a non-negative number (CIEDE2000 Delta E)' - ) - ); - }); - - it('should fail with threshold below 0', () => { - let errors = validateTddOptions('pnpm test', { threshold: '-0.1' }); - assert.ok( - errors.includes( - 'Threshold must be a non-negative number (CIEDE2000 Delta E)' - ) - ); + it('reports invalid command, port, and timeout values', () => { + let errors = validateTddOptions('', { + port: 'invalid', + timeout: '500', }); - it('should pass with threshold above 1 (CIEDE2000 allows values > 1)', () => { - let errors = validateTddOptions('pnpm test', { threshold: '2.0' }); - assert.strictEqual(errors.length, 0); - }); + assert.deepStrictEqual(errors, [ + 'Test command is required', + 'Port must be a valid number between 1 and 65535', + 'Timeout must be at least 1000 milliseconds', + ]); }); - describe('min cluster size validation', () => { - it('should pass with a positive integer', () => { - let errors = validateTddOptions('pnpm test', { minClusterSize: '2' }); - assert.strictEqual(errors.length, 0); + it('rejects invalid thresholds, including trailing text and negatives', () => { + let errors = validateTddOptions('pnpm test', { + threshold: '2abc', }); - it('should fail with zero', () => { - let errors = validateTddOptions('pnpm test', { minClusterSize: '0' }); - assert.ok( - errors.includes('Min cluster size must be a positive integer') - ); - }); + assert.deepStrictEqual(errors, [ + 'Threshold must be a non-negative number (CIEDE2000 Delta E)', + ]); - it('should fail with a decimal', () => { - let errors = validateTddOptions('pnpm test', { minClusterSize: '2.5' }); - assert.ok( - errors.includes('Min cluster size must be a positive integer') - ); - }); + assert.deepStrictEqual( + validateTddOptions('pnpm test', { threshold: '-0.1' }), + ['Threshold must be a non-negative number (CIEDE2000 Delta E)'] + ); }); - describe('multiple validation errors', () => { - it('should return all validation errors', () => { - let errors = validateTddOptions('', { - port: 'invalid', - timeout: '500', - threshold: '-1', - }); + it('rejects non-positive or fractional minimum cluster sizes', () => { + let invalidValues = ['0', '2.5', '-1']; - assert.strictEqual(errors.length, 4); - assert.ok(errors.includes('Test command is required')); - assert.ok( - errors.includes('Port must be a valid number between 1 and 65535') + for (let value of invalidValues) { + assert.deepStrictEqual( + validateTddOptions('pnpm test', { minClusterSize: value }), + ['Min cluster size must be a positive integer'] ); - assert.ok( - errors.includes('Timeout must be at least 1000 milliseconds') - ); - assert.ok( - errors.includes( - 'Threshold must be a non-negative number (CIEDE2000 Delta E)' - ) - ); - }); + } }); }); }); diff --git a/tests/commands/upload-cli.test.js b/tests/commands/upload-cli.test.js index 4e1496f6..bbb1dca4 100644 --- a/tests/commands/upload-cli.test.js +++ b/tests/commands/upload-cli.test.js @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, it } from 'node:test'; -import { runCLI } from '../helpers/cli-runner.js'; +import { findJSONMessage, runCLI } from '../helpers/cli-runner.js'; describe('commands/upload CLI', () => { it('reports a missing screenshots path before auth errors', async () => { @@ -14,4 +18,170 @@ describe('commands/upload CLI', () => { assert.match(result.stderr, /does not exist/); assert.doesNotMatch(result.stderr, /API token required/); }); + + it('reports a missing API token for an existing screenshot directory', async () => { + let workspace = mkdtempSync(join(tmpdir(), 'vizzly-cli-upload-')); + let screenshots = join(workspace, 'screenshots'); + mkdirSync(screenshots); + writeFileSync(join(screenshots, 'home-chrome.png'), 'png'); + + let result = await runCLI(['--no-color', 'upload', screenshots], { + cwd: workspace, + }); + + assert.strictEqual(result.code, 1); + assert.match(result.stderr, /API token required/); + }); + + it('uploads screenshots and finalizes the build through the CLI', async () => { + let workspace = mkdtempSync(join(tmpdir(), 'vizzly-cli-upload-')); + let screenshots = join(workspace, 'screenshots'); + mkdirSync(screenshots); + writeFileSync(join(screenshots, 'home-chrome.png'), 'png'); + writeFileSync(join(screenshots, 'settings-firefox.png'), 'png'); + + await withUploadApiServer(async ({ apiUrl, requests }) => { + let result = await runCLI( + [ + '--no-color', + '--json', + 'upload', + screenshots, + '--build-name', + 'CLI upload', + '--branch', + 'main', + '--commit', + 'abc123', + '--message', + 'Upload screenshots', + ], + { + cwd: workspace, + env: { + VIZZLY_API_URL: apiUrl, + VIZZLY_TOKEN: 'vzt_test_token', + }, + } + ); + + assert.strictEqual(result.code, 0); + assert.doesNotMatch(result.stderr, /status":"error/); + + let payload = findJSONMessage(result.stdout, 'data'); + assert.deepStrictEqual(payload.data.stats, { + total: 2, + uploaded: 2, + skipped: 0, + bytes: 0, + }); + assert.strictEqual(payload.data.buildId, 'build-123'); + assert.strictEqual( + payload.data.url, + `${apiUrl}/acme/web/builds/build-123` + ); + + assert.deepStrictEqual( + requests.map(request => `${request.method} ${request.url}`), + [ + 'POST /api/sdk/builds', + 'POST /api/sdk/check-shas', + 'POST /api/sdk/upload', + 'PUT /api/sdk/builds/build-123/status', + 'GET /api/sdk/token/context', + ] + ); + + assert.strictEqual(requests[0].json.build.name, 'CLI upload'); + assert.strictEqual(requests[0].json.build.branch, 'main'); + assert.strictEqual(requests[0].json.build.commit_sha, 'abc123'); + assert.strictEqual( + requests[0].json.build.commit_message, + 'Upload screenshots' + ); + assert.strictEqual(requests[0].json.build.environment, 'test'); + assert.match(requests[2].contentType, /multipart\/form-data/); + assert.ok(requests[2].body.includes('home-chrome.png')); + assert.ok(requests[2].body.includes('settings-firefox.png')); + assert.strictEqual(requests[3].json.status, 'completed'); + }); + }); }); + +async function withUploadApiServer(callback) { + let requests = []; + let server = createServer(async (req, res) => { + let body = await readRequestBody(req); + let request = { + method: req.method, + url: req.url, + contentType: req.headers['content-type'] || '', + body, + json: body.length > 0 ? parseJSONBody(body) : null, + }; + requests.push(request); + + res.setHeader('content-type', 'application/json'); + + if (req.method === 'POST' && req.url === '/api/sdk/builds') { + return respond(res, { id: 'build-123' }); + } + + if (req.method === 'POST' && req.url === '/api/sdk/check-shas') { + return respond(res, { existing: [], missing: [], screenshots: [] }); + } + + if (req.method === 'POST' && req.url === '/api/sdk/upload') { + return respond(res, {}); + } + + if ( + req.method === 'PUT' && + req.url === '/api/sdk/builds/build-123/status' + ) { + return respond(res, { status: 'completed' }); + } + + if (req.method === 'GET' && req.url === '/api/sdk/token/context') { + return respond(res, { + organization: { slug: 'acme' }, + project: { slug: 'web' }, + }); + } + + res.statusCode = 404; + return respond(res, { error: 'not found' }); + }); + + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + + try { + let address = server.address(); + return await callback({ + apiUrl: `http://127.0.0.1:${address.port}`, + requests, + }); + } finally { + await new Promise(resolve => server.close(resolve)); + } +} + +function readRequestBody(req) { + return new Promise(resolve => { + let chunks = []; + req.on('data', chunk => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks))); + }); +} + +function parseJSONBody(body) { + try { + return JSON.parse(body); + } catch { + return null; + } +} + +function respond(res, payload) { + res.end(JSON.stringify(payload)); +} diff --git a/tests/commands/upload.test.js b/tests/commands/upload.test.js index 36231d8c..2caae256 100644 --- a/tests/commands/upload.test.js +++ b/tests/commands/upload.test.js @@ -1,278 +1,47 @@ -import assert from 'node:assert'; +import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { constructBuildUrl, - uploadCommand, validateUploadOptions, } from '../../src/commands/upload.js'; -/** - * Create mock output object that tracks calls - */ -function createMockOutput() { - let calls = []; - return { - calls, - configure: opts => calls.push({ method: 'configure', args: [opts] }), - info: msg => calls.push({ method: 'info', args: [msg] }), - debug: (msg, data) => calls.push({ method: 'debug', args: [msg, data] }), - error: (msg, err) => calls.push({ method: 'error', args: [msg, err] }), - success: msg => calls.push({ method: 'success', args: [msg] }), - warn: msg => calls.push({ method: 'warn', args: [msg] }), - data: value => calls.push({ method: 'data', args: [value] }), - progress: (msg, cur, tot) => - calls.push({ method: 'progress', args: [msg, cur, tot] }), - startSpinner: msg => calls.push({ method: 'startSpinner', args: [msg] }), - stopSpinner: () => calls.push({ method: 'stopSpinner', args: [] }), - cleanup: () => calls.push({ method: 'cleanup', args: [] }), - // TUI helpers - complete: (msg, opts) => - calls.push({ method: 'complete', args: [msg, opts] }), - keyValue: (data, opts) => - calls.push({ method: 'keyValue', args: [data, opts] }), - labelValue: (label, value, opts) => - calls.push({ method: 'labelValue', args: [label, value, opts] }), - blank: () => calls.push({ method: 'blank', args: [] }), - print: msg => calls.push({ method: 'print', args: [msg] }), - link: (_label, url) => url, // Return the URL for testing - getColors: () => ({ - brand: { - success: s => s, - danger: s => s, - }, - }), - }; -} - describe('validateUploadOptions', () => { - describe('screenshots path validation', () => { - it('should pass with valid screenshots path', () => { - let errors = validateUploadOptions('./screenshots', {}); - assert.strictEqual(errors.length, 0); - }); - - it('should fail with missing screenshots path', () => { - let errors = validateUploadOptions(null, {}); - assert.ok(errors.includes('Screenshots path is required')); - }); - - it('should fail with empty screenshots path', () => { - let errors = validateUploadOptions('', {}); - assert.ok(errors.includes('Screenshots path is required')); - }); - }); - - describe('metadata validation', () => { - it('should pass with valid JSON metadata', () => { - let errors = validateUploadOptions('./screenshots', { - metadata: '{"version": "1.0.0"}', - }); - assert.strictEqual(errors.length, 0); - }); - - it('should fail with invalid JSON metadata', () => { - let errors = validateUploadOptions('./screenshots', { - metadata: 'invalid-json', - }); - assert.ok(errors.includes('Invalid JSON in --metadata option')); - }); - - it('should pass when metadata is not provided', () => { - let errors = validateUploadOptions('./screenshots', {}); - assert.strictEqual(errors.length, 0); - }); - }); - - describe('threshold validation', () => { - it('should pass with valid threshold', () => { - let errors = validateUploadOptions('./screenshots', { - threshold: '0.1', - }); - assert.strictEqual(errors.length, 0); - }); - - it('should pass with threshold of 0', () => { - let errors = validateUploadOptions('./screenshots', { threshold: '0' }); - assert.strictEqual(errors.length, 0); - }); - - it('should pass with threshold of 1', () => { - let errors = validateUploadOptions('./screenshots', { threshold: '1' }); - assert.strictEqual(errors.length, 0); - }); - - it('should fail with invalid threshold', () => { - let errors = validateUploadOptions('./screenshots', { - threshold: 'invalid', - }); - assert.ok( - errors.includes( - 'Threshold must be a non-negative number (CIEDE2000 Delta E)' - ) - ); - }); - - it('should fail when threshold has trailing text', () => { - let errors = validateUploadOptions('./screenshots', { - threshold: '2abc', - }); - assert.ok( - errors.includes( - 'Threshold must be a non-negative number (CIEDE2000 Delta E)' - ) - ); - }); - - it('should fail with threshold below 0', () => { - let errors = validateUploadOptions('./screenshots', { - threshold: '-0.1', - }); - assert.ok( - errors.includes( - 'Threshold must be a non-negative number (CIEDE2000 Delta E)' - ) - ); - }); - - it('should pass with threshold above 1 (CIEDE2000 allows values > 1)', () => { - let errors = validateUploadOptions('./screenshots', { - threshold: '2.0', - }); - assert.strictEqual(errors.length, 0); - }); - }); - - describe('min cluster size validation', () => { - it('should pass with valid min cluster size', () => { - let errors = validateUploadOptions('./screenshots', { + it('accepts a valid upload configuration', () => { + assert.deepStrictEqual( + validateUploadOptions('./screenshots', { + metadata: '{"suite":"visual"}', + threshold: '2', minClusterSize: '2', - }); - assert.strictEqual(errors.length, 0); - }); - - it('should fail with decimal min cluster size', () => { - let errors = validateUploadOptions('./screenshots', { - minClusterSize: '2.5', - }); - assert.ok(errors.includes('Min cluster size must be a positive integer')); - }); - - it('should fail with zero min cluster size', () => { - let errors = validateUploadOptions('./screenshots', { - minClusterSize: '0', - }); - assert.ok(errors.includes('Min cluster size must be a positive integer')); - }); - }); - - describe('batch size validation', () => { - it('should pass with valid batch size', () => { - let errors = validateUploadOptions('./screenshots', { batchSize: '10', - }); - assert.strictEqual(errors.length, 0); - }); - - it('should fail with invalid batch size', () => { - let errors = validateUploadOptions('./screenshots', { - batchSize: 'invalid', - }); - assert.ok(errors.includes('Batch size must be a positive integer')); - }); - - it('should fail with decimal batch size', () => { - let errors = validateUploadOptions('./screenshots', { - batchSize: '2.5', - }); - assert.ok(errors.includes('Batch size must be a positive integer')); - }); - - it('should fail with zero batch size', () => { - let errors = validateUploadOptions('./screenshots', { batchSize: '0' }); - assert.ok(errors.includes('Batch size must be a positive integer')); - }); - - it('should fail with negative batch size', () => { - let errors = validateUploadOptions('./screenshots', { - batchSize: '-5', - }); - assert.ok(errors.includes('Batch size must be a positive integer')); - }); - }); - - describe('upload timeout validation', () => { - it('should pass with valid upload timeout', () => { - let errors = validateUploadOptions('./screenshots', { uploadTimeout: '30000', - }); - assert.strictEqual(errors.length, 0); - }); - - it('should fail with invalid upload timeout', () => { - let errors = validateUploadOptions('./screenshots', { - uploadTimeout: 'invalid', - }); - assert.ok( - errors.includes( - 'Upload timeout must be a positive integer (milliseconds)' - ) - ); - }); - - it('should fail with decimal upload timeout', () => { - let errors = validateUploadOptions('./screenshots', { - uploadTimeout: '2500.5', - }); - assert.ok( - errors.includes( - 'Upload timeout must be a positive integer (milliseconds)' - ) - ); - }); - - it('should fail with zero upload timeout', () => { - let errors = validateUploadOptions('./screenshots', { - uploadTimeout: '0', - }); - assert.ok( - errors.includes( - 'Upload timeout must be a positive integer (milliseconds)' - ) - ); - }); + }), + [] + ); }); - describe('multiple validation errors', () => { - it('should return all validation errors', () => { - let errors = validateUploadOptions(null, { - metadata: 'invalid-json', - threshold: '-1', - minClusterSize: '0', - batchSize: '-1', - uploadTimeout: '0', - }); - - assert.strictEqual(errors.length, 6); - assert.ok(errors.includes('Screenshots path is required')); - assert.ok(errors.includes('Invalid JSON in --metadata option')); - assert.ok( - errors.includes( - 'Threshold must be a non-negative number (CIEDE2000 Delta E)' - ) - ); - assert.ok(errors.includes('Batch size must be a positive integer')); - assert.ok(errors.includes('Min cluster size must be a positive integer')); - assert.ok( - errors.includes( - 'Upload timeout must be a positive integer (milliseconds)' - ) - ); - }); + it('reports malformed metadata and numeric options', () => { + assert.deepStrictEqual( + validateUploadOptions(null, { + metadata: 'not-json', + threshold: '2abc', + minClusterSize: '2.5', + batchSize: '0', + uploadTimeout: '-1', + }), + [ + 'Screenshots path is required', + 'Invalid JSON in --metadata option', + 'Threshold must be a non-negative number (CIEDE2000 Delta E)', + 'Min cluster size must be a positive integer', + 'Batch size must be a positive integer', + 'Upload timeout must be a positive integer (milliseconds)', + ] + ); }); }); describe('constructBuildUrl', () => { - it('returns URL with org/project when context is available', async () => { + it('uses the organization and project from token context', async () => { let url = await constructBuildUrl( 'build-123', 'https://app.vizzly.dev/api', @@ -283,7 +52,6 @@ describe('constructBuildUrl', () => { organization: { slug: 'my-org' }, project: { slug: 'my-project' }, }), - output: createMockOutput(), } ); @@ -293,57 +61,23 @@ describe('constructBuildUrl', () => { ); }); - it('returns fallback URL when context fetch fails', async () => { - let output = createMockOutput(); - + it('falls back to a build URL when context is unavailable', async () => { let url = await constructBuildUrl( 'build-123', - 'https://app.vizzly.dev/api', + 'https://api.example.com/api/v1', 'test-token', { createApiClient: () => ({}), getTokenContext: async () => { - throw new Error('Failed'); + throw new Error('context unavailable'); }, - output, } ); - assert.strictEqual(url, 'https://app.vizzly.dev/builds/build-123'); - assert.ok(output.calls.some(c => c.method === 'debug')); - }); - - it('returns fallback URL when context missing org/project', async () => { - let url = await constructBuildUrl( - 'build-123', - 'https://app.vizzly.dev/api', - 'test-token', - { - createApiClient: () => ({}), - getTokenContext: async () => ({}), - output: createMockOutput(), - } - ); - - assert.strictEqual(url, 'https://app.vizzly.dev/builds/build-123'); - }); - - it('strips /api from base URL', async () => { - let url = await constructBuildUrl( - 'build-123', - 'https://custom.example.com/api/v1', - 'test-token', - { - createApiClient: () => ({}), - getTokenContext: async () => ({}), - output: createMockOutput(), - } - ); - - assert.strictEqual(url, 'https://custom.example.com/builds/build-123'); + assert.strictEqual(url, 'https://api.example.com/builds/build-123'); }); - it('does not strip api from hostnames when building fallback URLs', async () => { + it('does not strip api from a hostname', async () => { let url = await constructBuildUrl( 'build-123', 'https://api.example.com/api/v1', @@ -351,639 +85,9 @@ describe('constructBuildUrl', () => { { createApiClient: () => ({}), getTokenContext: async () => ({}), - output: createMockOutput(), } ); assert.strictEqual(url, 'https://api.example.com/builds/build-123'); }); }); - -describe('uploadCommand', () => { - it('returns error when no API key', async () => { - let output = createMockOutput(); - let exitCode = null; - - let result = await uploadCommand( - './screenshots', - {}, - {}, - { - loadConfig: async () => ({ - apiKey: null, - apiUrl: 'https://api.test', - build: { environment: 'test' }, - comparison: { threshold: 2.0 }, - }), - output, - exit: code => { - exitCode = code; - }, - } - ); - - assert.strictEqual(result.success, false); - assert.strictEqual(result.reason, 'no-api-key'); - assert.strictEqual(exitCode, 1); - }); - - it('uploads screenshots and finalizes build', async () => { - let output = createMockOutput(); - let uploadCalled = false; - let capturedUploadOptions = null; - let finalizeCalled = false; - let finalizeSuccess = null; - - let result = await uploadCommand( - './screenshots', - {}, - {}, - { - loadConfig: async () => ({ - apiKey: 'test-token', - apiUrl: 'https://api.test', - build: { environment: 'test' }, - comparison: { threshold: 2.0, minClusterSize: 3 }, - }), - detectBranch: async () => 'main', - detectCommit: async () => 'abc123', - detectCommitMessage: async () => 'Test commit', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async () => 'Test Build', - createUploader: () => ({ - upload: async uploadOptions => { - uploadCalled = true; - capturedUploadOptions = uploadOptions; - return { - buildId: 'build-123', - stats: { uploaded: 5, total: 5 }, - }; - }, - }), - createApiClient: () => ({}), - finalizeBuild: async (_client, _buildId, success) => { - finalizeCalled = true; - finalizeSuccess = success; - }, - buildUrlConstructor: async () => - 'https://app.vizzly.dev/builds/build-123', - output, - exit: () => {}, - } - ); - - assert.strictEqual(result.success, true); - assert.strictEqual(uploadCalled, true); - assert.strictEqual(capturedUploadOptions.threshold, 2.0); - assert.strictEqual(capturedUploadOptions.minClusterSize, 3); - assert.strictEqual(finalizeCalled, true); - assert.strictEqual(finalizeSuccess, true); - // Now uses output.complete() instead of output.success() - assert.ok(output.calls.some(c => c.method === 'complete')); - }); - - it('passes upload flags, metadata, and session fields through', async () => { - let output = createMockOutput(); - let capturedUploadOptions = null; - let capturedSession = null; - - await uploadCommand( - './screenshots', - { - uploadAll: true, - metadata: '{"suite":"visual","browser":"chrome"}', - }, - {}, - { - loadConfig: async () => ({ - apiKey: 'test-token', - apiUrl: 'https://api.test', - build: { environment: 'preview' }, - comparison: { threshold: 1.5, minClusterSize: 4 }, - parallelId: 'parallel-123', - }), - detectBranch: async () => 'feature/upload', - detectCommit: async () => 'abc123', - detectCommitMessage: async () => 'Upload snapshots', - detectPullRequestNumber: () => 42, - generateBuildNameWithGit: async () => 'Upload Build', - createUploader: () => ({ - upload: async uploadOptions => { - capturedUploadOptions = uploadOptions; - return { - buildId: 'build-123', - stats: { uploaded: 2, total: 2 }, - }; - }, - }), - createApiClient: () => ({}), - finalizeBuild: async () => {}, - buildUrlConstructor: async () => - 'https://app.vizzly.dev/builds/build-123', - writeSession: session => { - capturedSession = session; - }, - output, - exit: () => {}, - } - ); - - assert.strictEqual(capturedUploadOptions.uploadAll, true); - assert.strictEqual(capturedUploadOptions.branch, 'feature/upload'); - assert.strictEqual(capturedUploadOptions.commit, 'abc123'); - assert.strictEqual(capturedUploadOptions.message, 'Upload snapshots'); - assert.strictEqual(capturedUploadOptions.environment, 'preview'); - assert.strictEqual(capturedUploadOptions.threshold, 1.5); - assert.strictEqual(capturedUploadOptions.minClusterSize, 4); - assert.strictEqual(capturedUploadOptions.pullRequestNumber, 42); - assert.strictEqual(capturedUploadOptions.parallelId, 'parallel-123'); - assert.deepStrictEqual(capturedUploadOptions.metadata, { - suite: 'visual', - browser: 'chrome', - }); - assert.deepStrictEqual(capturedSession, { - buildId: 'build-123', - branch: 'feature/upload', - commit: 'abc123', - parallelId: 'parallel-123', - }); - }); - - it('passes resolved upload config to the uploader factory', async () => { - let output = createMockOutput(); - let capturedLoadOptions = null; - let capturedUploaderConfig = null; - - await uploadCommand( - './screenshots', - { - batchSize: 7, - uploadTimeout: 45_000, - }, - {}, - { - loadConfig: async (_configPath, allOptions) => { - capturedLoadOptions = allOptions; - return { - apiKey: 'test-token', - apiUrl: 'https://api.test', - build: { environment: 'test' }, - comparison: { threshold: 2, minClusterSize: 4 }, - upload: { - batchSize: allOptions.batchSize, - timeout: allOptions.uploadTimeout, - }, - }; - }, - detectBranch: async () => 'main', - detectCommit: async () => 'abc123', - detectCommitMessage: async () => 'Upload snapshots', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async () => 'Upload Build', - createUploader: config => { - capturedUploaderConfig = config; - return { - upload: async () => ({ - buildId: 'build-123', - stats: { uploaded: 2, total: 2 }, - }), - }; - }, - createApiClient: () => ({}), - finalizeBuild: async () => {}, - buildUrlConstructor: async () => - 'https://app.vizzly.dev/builds/build-123', - writeSession: () => {}, - output, - exit: () => {}, - } - ); - - assert.strictEqual(capturedLoadOptions.batchSize, 7); - assert.strictEqual(capturedLoadOptions.uploadTimeout, 45_000); - assert.deepStrictEqual(capturedUploaderConfig.upload, { - batchSize: 7, - timeout: 45_000, - }); - assert.strictEqual(capturedUploaderConfig.command, 'upload'); - }); - - it('uses configured build metadata when CLI overrides are absent', async () => { - let output = createMockOutput(); - let capturedUploadOptions = null; - - await uploadCommand( - './screenshots', - {}, - {}, - { - loadConfig: async () => ({ - apiKey: 'test-token', - apiUrl: 'https://api.test', - build: { - name: 'Configured Upload', - branch: 'config-branch', - commit: 'config-sha', - message: 'Config upload message', - environment: 'preview', - }, - comparison: { threshold: 2, minClusterSize: 4 }, - }), - detectBranch: async branch => branch, - detectCommit: async commit => commit, - detectCommitMessage: async () => { - throw new Error('should not detect message'); - }, - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async name => name, - createUploader: () => ({ - upload: async uploadOptions => { - capturedUploadOptions = uploadOptions; - return { - buildId: 'build-123', - stats: { uploaded: 2, total: 2 }, - }; - }, - }), - createApiClient: () => ({}), - finalizeBuild: async () => {}, - buildUrlConstructor: async () => - 'https://app.vizzly.dev/builds/build-123', - writeSession: () => {}, - output, - exit: () => {}, - } - ); - - assert.strictEqual(capturedUploadOptions.branch, 'config-branch'); - assert.strictEqual(capturedUploadOptions.commit, 'config-sha'); - assert.strictEqual(capturedUploadOptions.message, 'Config upload message'); - assert.strictEqual(capturedUploadOptions.buildName, 'Configured Upload'); - assert.strictEqual(capturedUploadOptions.environment, 'preview'); - }); - - it('handles upload errors and marks build as failed', async () => { - let output = createMockOutput(); - let exitCode = null; - let finalizeSuccess = null; - - let result = await uploadCommand( - './screenshots', - {}, - {}, - { - loadConfig: async () => ({ - apiKey: 'test-token', - apiUrl: 'https://api.test', - build: { environment: 'test' }, - comparison: { threshold: 2.0 }, - }), - detectBranch: async () => 'main', - detectCommit: async () => 'abc123', - detectCommitMessage: async () => 'Test commit', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async () => 'Test Build', - createUploader: () => ({ - upload: async opts => { - // Simulate progress callback that sets buildId - if (opts.onProgress) { - opts.onProgress({ buildId: 'build-123' }); - } - throw new Error('Upload failed'); - }, - }), - createApiClient: () => ({}), - finalizeBuild: async (_client, _buildId, success) => { - finalizeSuccess = success; - }, - output, - exit: code => { - exitCode = code; - }, - } - ); - - assert.strictEqual(result.success, false); - assert.strictEqual(exitCode, 1); - assert.strictEqual(finalizeSuccess, false); - }); - - it('waits for build completion when wait option is set', async () => { - let output = createMockOutput(); - let waitForBuildCalled = false; - - let result = await uploadCommand( - './screenshots', - { wait: true }, - {}, - { - loadConfig: async () => ({ - apiKey: 'test-token', - apiUrl: 'https://api.test', - build: { environment: 'test' }, - comparison: { threshold: 2.0 }, - }), - detectBranch: async () => 'main', - detectCommit: async () => 'abc123', - detectCommitMessage: async () => 'Test commit', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async () => 'Test Build', - createUploader: () => ({ - upload: async () => ({ - buildId: 'build-123', - stats: { uploaded: 5, total: 5 }, - }), - waitForBuild: async () => { - waitForBuildCalled = true; - return { passedComparisons: 5, failedComparisons: 0 }; - }, - }), - createApiClient: () => ({}), - finalizeBuild: async () => {}, - buildUrlConstructor: async () => - 'https://app.vizzly.dev/builds/build-123', - output, - exit: () => {}, - } - ); - - assert.strictEqual(result.success, true); - assert.strictEqual(waitForBuildCalled, true); - }); - - it('shows warning when wait returns failed comparisons', async () => { - let output = createMockOutput(); - - let result = await uploadCommand( - './screenshots', - { wait: true }, - {}, - { - loadConfig: async () => ({ - apiKey: 'test-token', - apiUrl: 'https://api.test', - build: { environment: 'test' }, - comparison: { threshold: 2.0 }, - }), - detectBranch: async () => 'main', - detectCommit: async () => 'abc123', - detectCommitMessage: async () => 'Test commit', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async () => 'Test Build', - createUploader: () => ({ - upload: async () => ({ - buildId: 'build-123', - stats: { uploaded: 5, total: 5 }, - }), - waitForBuild: async () => ({ - passedComparisons: 3, - failedComparisons: 2, - }), - }), - createApiClient: () => ({}), - finalizeBuild: async () => {}, - buildUrlConstructor: async () => - 'https://app.vizzly.dev/builds/build-123', - output, - exit: () => {}, - } - ); - - // Now uses output.print() with styled text instead of warn - assert.ok( - output.calls.some( - c => c.method === 'print' && c.args[0].includes('failed') - ) - ); - assert.strictEqual(result.success, false); - assert.strictEqual(result.exitCode, 1); - }); - - it('handles finalize error gracefully', async () => { - let output = createMockOutput(); - - let result = await uploadCommand( - './screenshots', - {}, - {}, - { - loadConfig: async () => ({ - apiKey: 'test-token', - apiUrl: 'https://api.test', - build: { environment: 'test' }, - comparison: { threshold: 2.0 }, - }), - detectBranch: async () => 'main', - detectCommit: async () => 'abc123', - detectCommitMessage: async () => 'Test commit', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async () => 'Test Build', - createUploader: () => ({ - upload: async () => ({ - buildId: 'build-123', - stats: { uploaded: 5, total: 5 }, - }), - }), - createApiClient: () => ({}), - finalizeBuild: async () => { - throw new Error('Finalize failed'); - }, - buildUrlConstructor: async () => - 'https://app.vizzly.dev/builds/build-123', - output, - exit: () => {}, - } - ); - - // Should still succeed since upload completed - assert.strictEqual(result.success, true); - // Should show warning about finalize failure - assert.ok(output.calls.some(c => c.method === 'warn')); - }); - - it('uses result.url when provided', async () => { - let output = createMockOutput(); - - await uploadCommand( - './screenshots', - {}, - {}, - { - loadConfig: async () => ({ - apiKey: 'test-token', - apiUrl: 'https://api.test', - build: { environment: 'test' }, - comparison: { threshold: 2.0 }, - }), - detectBranch: async () => 'main', - detectCommit: async () => 'abc123', - detectCommitMessage: async () => 'Test commit', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async () => 'Test Build', - createUploader: () => ({ - upload: async () => ({ - buildId: 'build-123', - url: 'https://custom.url/build', - stats: { uploaded: 5, total: 5 }, - }), - }), - createApiClient: () => ({}), - finalizeBuild: async () => {}, - buildUrlConstructor: async () => { - throw new Error('Should not be called'); - }, - output, - exit: () => {}, - } - ); - - // Check that the custom URL was used (now via labelValue) - assert.ok( - output.calls.some( - c => - c.method === 'labelValue' && c.args[1] === 'https://custom.url/build' - ) - ); - }); - - it('shows verbose output when verbose flag is set', async () => { - let output = createMockOutput(); - - await uploadCommand( - './screenshots', - {}, - { verbose: true }, - { - loadConfig: async () => ({ - apiKey: 'test-token', - apiUrl: 'https://api.test', - build: { environment: 'test', name: 'Test' }, - comparison: { threshold: 2.0 }, - }), - detectBranch: async () => 'main', - detectCommit: async () => 'abc123', - detectCommitMessage: async () => 'Test commit', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async () => 'Test Build', - createUploader: () => ({ - upload: async () => ({ - buildId: 'build-123', - stats: { uploaded: 5, total: 5 }, - }), - }), - createApiClient: () => ({}), - finalizeBuild: async () => {}, - buildUrlConstructor: async () => - 'https://app.vizzly.dev/builds/build-123', - output, - exit: () => {}, - } - ); - - assert.ok(output.calls.some(c => c.method === 'debug')); - }); - - it('does not fail CI when API returns 5xx error', async () => { - let output = createMockOutput(); - let exitCode = null; - - let apiError = new Error('API request failed: 503 - Service Unavailable'); - apiError.context = { status: 503 }; - - let result = await uploadCommand( - './screenshots', - {}, - {}, - { - loadConfig: async () => ({ - apiKey: 'test-token', - apiUrl: 'https://api.test', - build: { environment: 'test' }, - comparison: { threshold: 2.0 }, - }), - detectBranch: async () => 'main', - detectCommit: async () => 'abc123', - detectCommitMessage: async () => 'Test commit', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async () => 'Test Build', - createUploader: () => ({ - upload: async () => { - throw apiError; - }, - }), - createApiClient: () => ({}), - finalizeBuild: async () => {}, - output, - exit: code => { - exitCode = code; - }, - } - ); - - assert.strictEqual(result.success, true); - assert.strictEqual(result.result.skipped, true); - assert.strictEqual(exitCode, null); - assert.ok( - output.calls.some( - c => c.method === 'warn' && c.args[0].includes('API unavailable') - ) - ); - }); - - it('emits a JSON skip payload when API returns 5xx error', async () => { - let output = createMockOutput(); - let exitCode = null; - - let apiError = new Error('API request failed: 503 - Service Unavailable'); - apiError.context = { status: 503 }; - - let result = await uploadCommand( - './screenshots', - {}, - { json: true }, - { - loadConfig: async () => ({ - apiKey: 'test-token', - apiUrl: 'https://api.test', - build: { environment: 'test' }, - comparison: { threshold: 2.0 }, - }), - detectBranch: async () => 'main', - detectCommit: async () => 'abc123', - detectCommitMessage: async () => 'Test commit', - detectPullRequestNumber: () => null, - generateBuildNameWithGit: async () => 'Test Build', - createUploader: () => ({ - upload: async () => { - throw apiError; - }, - }), - createApiClient: () => ({}), - finalizeBuild: async () => {}, - output, - exit: code => { - exitCode = code; - }, - } - ); - - let dataCall = output.calls.find(c => c.method === 'data'); - - assert.deepStrictEqual( - { - buildId: dataCall.args[0].buildId, - status: dataCall.args[0].status, - message: dataCall.args[0].message, - }, - { - buildId: null, - status: 'skipped', - message: 'Vizzly API unavailable - upload skipped', - } - ); - assert.strictEqual(typeof dataCall.args[0].executionTimeMs, 'number'); - assert.strictEqual(result.success, true); - assert.strictEqual(result.result.skipped, true); - assert.strictEqual(exitCode, null); - }); -}); diff --git a/tests/commands/whoami.test.js b/tests/commands/whoami.test.js index 85e9720b..62d03bb7 100644 --- a/tests/commands/whoami.test.js +++ b/tests/commands/whoami.test.js @@ -2,7 +2,6 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { getTokenExpiryStatus, - validateWhoamiOptions, whoamiCommand, } from '../../src/commands/whoami.js'; @@ -54,12 +53,6 @@ let response = { }; describe('commands/whoami', () => { - describe('validateWhoamiOptions', () => { - it('returns no errors', () => { - assert.deepStrictEqual(validateWhoamiOptions({}), []); - }); - }); - describe('getTokenExpiryStatus', () => { it('formats expired tokens with a refresh hint', () => { let result = getTokenExpiryStatus( @@ -120,30 +113,19 @@ describe('commands/whoami', () => { it('fetches user info with the configured auth client', async () => { let output = createMockOutput(); - let capturedBaseUrl = null; - let capturedTokenStore = null; await whoamiCommand( { apiUrl: 'https://api.example.test' }, { json: true }, { getAuthTokens: async () => auth, - createAuthClient: ({ baseUrl }) => { - capturedBaseUrl = baseUrl; - return { kind: 'client' }; - }, + createAuthClient: () => ({ kind: 'client' }), createTokenStore: () => ({ kind: 'store' }), - whoami: async (_client, tokenStore) => { - capturedTokenStore = tokenStore; - return response; - }, + whoami: async () => response, output, } ); - assert.strictEqual(capturedBaseUrl, 'https://api.example.test'); - assert.deepStrictEqual(capturedTokenStore, { kind: 'store' }); - let dataCall = output.calls.find(call => call.method === 'data'); assert.strictEqual(dataCall.args[0].authenticated, true); assert.deepStrictEqual(dataCall.args[0].user, response.user); diff --git a/tests/server-manager/index.test.js b/tests/server-manager/index.test.js index a4fca92b..c48cc0d7 100644 --- a/tests/server-manager/index.test.js +++ b/tests/server-manager/index.test.js @@ -10,15 +10,6 @@ import { describe('server-manager/index', () => { describe('createServerManager', () => { - it('creates manager with start, stop, getTddResults, and server', () => { - let manager = createServerManager({ server: { port: 47392 } }); - - assert.ok(manager.start); - assert.ok(manager.stop); - assert.ok(manager.getTddResults); - assert.ok('server' in manager); - }); - describe('start', () => { it('starts TDD server when tddMode is true', async () => { let httpServerStarted = false; diff --git a/tests/server/handlers/api-handler.test.js b/tests/server/handlers/api-handler.test.js index 5fdea500..fa063d58 100644 --- a/tests/server/handlers/api-handler.test.js +++ b/tests/server/handlers/api-handler.test.js @@ -28,15 +28,6 @@ describe('server/handlers/api-handler', () => { }); describe('createApiHandler', () => { - it('creates handler with required methods', () => { - let handler = createApiHandler(null); - - assert.strictEqual(typeof handler.handleScreenshot, 'function'); - assert.strictEqual(typeof handler.getScreenshotCount, 'function'); - assert.strictEqual(typeof handler.flush, 'function'); - assert.strictEqual(typeof handler.cleanup, 'function'); - }); - it('getScreenshotCount returns 0 initially', () => { let handler = createApiHandler(null); diff --git a/tests/server/handlers/tdd-handler.test.js b/tests/server/handlers/tdd-handler.test.js index e117a513..82f71b38 100644 --- a/tests/server/handlers/tdd-handler.test.js +++ b/tests/server/handlers/tdd-handler.test.js @@ -462,110 +462,6 @@ describe('server/handlers/tdd-handler', () => { }); describe('createTddHandler', () => { - it('creates handler with all required methods', () => { - let deps = createMockDeps(); - let handler = createTddHandler({}, '/test', null, null, false, deps); - - assert.ok(typeof handler.initialize === 'function'); - assert.ok(typeof handler.handleScreenshot === 'function'); - assert.ok(typeof handler.getResults === 'function'); - assert.ok(typeof handler.acceptBaseline === 'function'); - assert.ok(typeof handler.rejectBaseline === 'function'); - assert.ok(typeof handler.acceptAllBaselines === 'function'); - assert.ok(typeof handler.resetBaselines === 'function'); - assert.ok(typeof handler.cleanup === 'function'); - assert.ok(handler.tddService); - }); - - describe('initialize', () => { - it('skips loading in setBaseline mode', async () => { - let deps = createMockDeps(); - let handler = createTddHandler({}, '/test', null, null, true, deps); - - await handler.initialize(); - - let debugCall = deps._mockOutput.calls.find( - c => - c.method === 'debug' && c.args[1]?.includes('baseline update mode') - ); - assert.ok(debugCall); - }); - - it('downloads baselines when build ID provided with API key', async () => { - let downloadCalled = false; - let deps = createMockDeps({ - tddServiceOverrides: { - downloadBaselines: () => { - downloadCalled = true; - return null; - }, - }, - }); - let handler = createTddHandler( - { apiKey: 'test-key' }, - '/test', - 'build-123', - null, - false, - deps - ); - - await handler.initialize(); - - assert.strictEqual(downloadCalled, true); - }); - - it('loads local baseline when no override flags', async () => { - let loadCalled = false; - let deps = createMockDeps({ - tddServiceOverrides: { - loadBaseline: () => { - loadCalled = true; - return { buildName: 'Local Build' }; - }, - }, - }); - let handler = createTddHandler({}, '/test', null, null, false, deps); - - await handler.initialize(); - - 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', () => { it('returns 200 with status match for passed comparison', async () => { let deps = createMockDeps({ @@ -659,93 +555,6 @@ describe('server/handlers/tdd-handler', () => { assert.ok(result.body.diff); }); - it('returns diff response with all required fields for SDK consumption', async () => { - let deps = createMockDeps({ - tddServiceOverrides: { - compareScreenshot: name => ({ - id: `comp-${name}`, - name, - status: 'failed', - baseline: '/test/.vizzly/baselines/homepage.png', - current: '/test/.vizzly/current/homepage.png', - diff: '/test/.vizzly/diffs/homepage.png', - diffPercentage: 12.34, - threshold: 5.0, - }), - }, - }); - let handler = createTddHandler({}, '/test', null, null, false, deps); - - let result = await handler.handleScreenshot( - 'build-1', - 'homepage', - 'base64imagedata', - {} - ); - - // Verify the response has all fields SDKs need - assert.strictEqual(result.statusCode, 200); - assert.strictEqual(typeof result.body.status, 'string'); - assert.strictEqual(typeof result.body.name, 'string'); - assert.strictEqual(typeof result.body.message, 'string'); - assert.strictEqual(typeof result.body.diffPercentage, 'number'); - assert.strictEqual(typeof result.body.threshold, 'number'); - assert.strictEqual(result.body.tddMode, true); - }); - - it('passes comparison metadata through to screenshot comparison', async () => { - let capturedProperties = null; - let deps = createMockDeps({ - tddServiceOverrides: { - compareScreenshot: (_name, _imageBuffer, properties) => { - capturedProperties = properties; - return { - id: 'comp-dashboard', - name: 'dashboard', - status: 'passed', - baseline: '/baselines/dashboard.png', - current: '/current/dashboard.png', - diff: null, - threshold: properties.threshold, - minClusterSize: properties.minClusterSize, - }; - }, - }, - }); - let handler = createTddHandler({}, '/test', null, null, false, deps); - - let result = await handler.handleScreenshot( - 'build-1', - 'dashboard', - 'base64imagedata', - { - browser: 'firefox', - viewport: { width: 1280, height: 720 }, - fullPage: false, - threshold: 0, - minClusterSize: 2, - } - ); - - assert.strictEqual(result.statusCode, 200); - assert.deepStrictEqual(capturedProperties, { - browser: 'firefox', - viewport: { width: 1280, height: 720 }, - fullPage: false, - threshold: 0, - minClusterSize: 2, - viewport_width: 1280, - viewport_height: 720, - metadata: { - browser: 'firefox', - viewport: { width: 1280, height: 720 }, - fullPage: false, - threshold: 0, - minClusterSize: 2, - }, - }); - }); - it('returns 400 for invalid screenshot name', async () => { let deps = createMockDeps({ sanitizeScreenshotName: () => { @@ -831,74 +640,6 @@ describe('server/handlers/tdd-handler', () => { assert.ok(result.body.error.includes('not found')); }); - it('uses explicit type parameter instead of detection', async () => { - let detectCalled = false; - let deps = createMockDeps({ - detectImageInputType: () => { - detectCalled = true; - return 'base64'; - }, - tddServiceOverrides: { - compareScreenshot: name => ({ - id: `comp-${name}`, - name, - status: 'passed', - baseline: '/baselines/test.png', - current: '/current/test.png', - diff: null, - }), - }, - }); - let handler = createTddHandler({}, '/test', null, null, false, deps); - - // Pass explicit type='base64' - detection should not be called - let result = await handler.handleScreenshot( - 'build-1', - 'test', - 'base64imagedata', - {}, - 'base64' - ); - - assert.strictEqual(result.statusCode, 200); - assert.strictEqual(detectCalled, false); - }); - - it('uses explicit file-path type parameter', async () => { - let detectCalled = false; - let deps = createMockDeps({ - detectImageInputType: () => { - detectCalled = true; - return 'base64'; // Would return wrong type if called - }, - existsSync: () => true, - readFileSync: () => Buffer.from('fake-png-data'), - tddServiceOverrides: { - compareScreenshot: name => ({ - id: `comp-${name}`, - name, - status: 'passed', - baseline: '/baselines/test.png', - current: '/current/test.png', - diff: null, - }), - }, - }); - let handler = createTddHandler({}, '/test', null, null, false, deps); - - // Pass explicit type='file-path' - detection should not be called - let result = await handler.handleScreenshot( - 'build-1', - 'test', - 'file:///path/to/screenshot.png', - {}, - 'file-path' - ); - - assert.strictEqual(result.statusCode, 200); - assert.strictEqual(detectCalled, false); - }); - it('falls back to detection when type not provided', async () => { let detectCalled = false; let deps = createMockDeps({ @@ -1029,110 +770,6 @@ describe('server/handlers/tdd-handler', () => { assert.strictEqual(result.statusCode, 400); assert.ok(result.body.error.includes('Invalid image input')); }); - - it('logs screenshot event with normalized status for menubar consumption', async () => { - let deps = createMockDeps({ - tddServiceOverrides: { - compareScreenshot: name => ({ - id: `comp-${name}`, - name, - status: 'failed', - diffPercentage: 5.5, - baseline: '/baselines/test.png', - current: '/current/test.png', - diff: '/diffs/test.png', - threshold: 2.0, - }), - }, - }); - let handler = createTddHandler({}, '/test', null, null, false, deps); - - await handler.handleScreenshot('build-1', 'homepage', 'base64data', {}); - - // Find the screenshot log - menubar looks for logs starting with "Screenshot:" - let screenshotLog = deps._mockOutput.calls.find( - c => c.method === 'info' && c.args[0].startsWith('Screenshot:') - ); - - assert.ok(screenshotLog, 'Screenshot event should be logged'); - assert.strictEqual(screenshotLog.args[1].screenshot, 'homepage'); - // Status should be normalized to 'diff' (not 'failed') to match HTTP response - assert.strictEqual(screenshotLog.args[1].status, 'diff'); - assert.strictEqual(screenshotLog.args[1].diffPercentage, 5.5); - }); - - it('logs passed screenshots with match-equivalent status', async () => { - let deps = createMockDeps({ - tddServiceOverrides: { - compareScreenshot: name => ({ - id: `comp-${name}`, - name, - status: 'passed', - baseline: '/baselines/test.png', - current: '/current/test.png', - diff: null, - }), - }, - }); - let handler = createTddHandler({}, '/test', null, null, false, deps); - - await handler.handleScreenshot('build-1', 'button', 'base64data', {}); - - let screenshotLog = deps._mockOutput.calls.find( - c => c.method === 'info' && c.args[0].startsWith('Screenshot:') - ); - - assert.ok(screenshotLog); - assert.strictEqual(screenshotLog.args[1].status, 'passed'); - assert.strictEqual(screenshotLog.args[1].diffPercentage, 0); - }); - - it('logs new baseline screenshots', async () => { - let deps = createMockDeps({ - tddServiceOverrides: { - compareScreenshot: name => ({ - id: `comp-${name}`, - name, - status: 'new', - baseline: '/baselines/test.png', - current: '/current/test.png', - diff: null, - }), - }, - }); - let handler = createTddHandler({}, '/test', null, null, false, deps); - - await handler.handleScreenshot( - 'build-1', - 'new-component', - 'base64data', - {} - ); - - let screenshotLog = deps._mockOutput.calls.find( - c => c.method === 'info' && c.args[0].startsWith('Screenshot:') - ); - - assert.ok(screenshotLog); - assert.strictEqual(screenshotLog.args[1].status, 'new'); - }); - }); - - describe('getResults', () => { - it('returns results from tddService', async () => { - let deps = createMockDeps({ - tddServiceOverrides: { - printResults: () => ({ total: 5, passed: 3, failed: 2 }), - }, - }); - let handler = createTddHandler({}, '/test', null, null, false, deps); - - let results = await handler.getResults(); - - assert.strictEqual(results.total, 5); - assert.strictEqual(results.passed, 3); - assert.strictEqual(results.failed, 2); - }); }); describe('acceptBaseline', () => { @@ -1174,43 +811,6 @@ describe('server/handlers/tdd-handler', () => { /Comparison not found/ ); }); - - it('logs screenshot event on acceptance for menubar consumption', async () => { - let deps = createMockDeps({ - tddServiceOverrides: { - acceptBaseline: () => ({ success: true }), - }, - }); - - let reportData = { - timestamp: Date.now(), - comparisons: [ - { - id: 'comp-1', - name: 'login-form', - status: 'failed', - diffPercentage: 2.1, - }, - ], - groups: [], - summary: { total: 1, passed: 0, failed: 1, errors: 0 }, - }; - deps._fileSystem['/test/.vizzly/report-data.json'] = - JSON.stringify(reportData); - - let handler = createTddHandler({}, '/test', null, null, false, deps); - - await handler.acceptBaseline('comp-1'); - - // Menubar looks for screenshot logs with structured metadata - let screenshotLog = deps._mockOutput.calls.find( - c => c.method === 'info' && c.args[0].startsWith('Screenshot:') - ); - assert.ok(screenshotLog, 'Screenshot event should be logged'); - assert.strictEqual(screenshotLog.args[1].screenshot, 'login-form'); - assert.strictEqual(screenshotLog.args[1].status, 'accepted'); - assert.strictEqual(screenshotLog.args[1].diffPercentage, 0); - }); }); describe('rejectBaseline', () => { @@ -1304,39 +904,6 @@ describe('server/handlers/tdd-handler', () => { assert.strictEqual(comparison.baseline, '/images/baselines/test.png'); assert.strictEqual(comparison.properties.browser, 'chrome'); }); - - it('logs screenshot event on rejection for menubar consumption', async () => { - let deps = createMockDeps(); - - let reportData = { - timestamp: Date.now(), - comparisons: [ - { - id: 'comp-1', - name: 'button-hover', - status: 'failed', - diffPercentage: 3.2, - }, - ], - groups: [], - summary: { total: 1, passed: 0, failed: 1, errors: 0 }, - }; - deps._fileSystem['/test/.vizzly/report-data.json'] = - JSON.stringify(reportData); - - let handler = createTddHandler({}, '/test', null, null, false, deps); - - await handler.rejectBaseline('comp-1'); - - // Menubar looks for screenshot logs with structured metadata - let screenshotLog = deps._mockOutput.calls.find( - c => c.method === 'info' && c.args[0].startsWith('Screenshot:') - ); - assert.ok(screenshotLog, 'Screenshot event should be logged'); - assert.strictEqual(screenshotLog.args[1].screenshot, 'button-hover'); - assert.strictEqual(screenshotLog.args[1].status, 'rejected'); - assert.strictEqual(screenshotLog.args[1].diffPercentage, 3.2); - }); }); describe('acceptAllBaselines', () => { @@ -1419,16 +986,6 @@ describe('server/handlers/tdd-handler', () => { }); }); - describe('cleanup', () => { - it('cleanup function exists and can be called', () => { - let deps = createMockDeps(); - let handler = createTddHandler({}, '/test', null, null, false, deps); - - // Should not throw - handler.cleanup(); - }); - }); - describe('readReportData / updateComparison', () => { it('creates empty report data when file does not exist', async () => { let deps = createMockDeps(); diff --git a/tests/server/routers/events.test.js b/tests/server/routers/events.test.js index f8c279b3..f6856471 100644 --- a/tests/server/routers/events.test.js +++ b/tests/server/routers/events.test.js @@ -242,31 +242,27 @@ describe('server/routers/events', () => { }); it('cleans up on connection close', async () => { - let handler = createEventsRouter({ workingDir: testDir }); + let harness = createEventsHarness(testDir); let req = createMockRequest('GET'); let res = createMockResponse(); - await handler(req, res, '/api/events'); + await harness.handler(req, res, '/api/events'); - // Simulate connection close req.emit('close'); - // Connection should be cleaned up (no error) - assert.ok(true); + assert.strictEqual(harness.cleanupCalled, true); }); it('cleans up on connection error', async () => { - let handler = createEventsRouter({ workingDir: testDir }); + let harness = createEventsHarness(testDir); let req = createMockRequest('GET'); let res = createMockResponse(); - await handler(req, res, '/api/events'); + await harness.handler(req, res, '/api/events'); - // Simulate connection error req.emit('error', new Error('Connection reset')); - // Connection should be cleaned up (no error) - assert.ok(true); + assert.strictEqual(harness.cleanupCalled, true); }); it('handles invalid JSON in report-data.json gracefully', async () => { diff --git a/tests/services/config-service.test.js b/tests/services/config-service.test.js index 7fee7096..84c06b17 100644 --- a/tests/services/config-service.test.js +++ b/tests/services/config-service.test.js @@ -56,16 +56,6 @@ describe('services/config-service', () => { delete process.env.VIZZLY_HOME; }); - describe('createConfigService', () => { - it('creates a config service with all methods', () => { - let service = createConfigService({ workingDir: tempDir }); - - assert.ok(service.getConfig, 'Should have getConfig method'); - assert.ok(service.updateConfig, 'Should have updateConfig method'); - assert.ok(service.validateConfig, 'Should have validateConfig method'); - }); - }); - describe('getConfig - merged', () => { it('returns default config when no config files exist', async () => { let service = createConfigService({ workingDir: tempDir }); diff --git a/tests/tdd/metadata/hotspot-metadata.test.js b/tests/tdd/metadata/hotspot-metadata.test.js index 4f3463b4..8ddefc01 100644 --- a/tests/tdd/metadata/hotspot-metadata.test.js +++ b/tests/tdd/metadata/hotspot-metadata.test.js @@ -127,14 +127,6 @@ describe('tdd/metadata/hotspot-metadata', () => { }); }); - describe('createHotspotCache', () => { - it('creates empty cache object', () => { - let cache = createHotspotCache(); - - assert.deepStrictEqual(cache, { data: null, loaded: false }); - }); - }); - describe('getHotspotForScreenshot', () => { it('returns null when cache is empty and no file exists', () => { let cache = createHotspotCache(); diff --git a/tests/tdd/runtime-context.test.js b/tests/tdd/runtime-context.test.js index 8b25a994..789014ec 100644 --- a/tests/tdd/runtime-context.test.js +++ b/tests/tdd/runtime-context.test.js @@ -2,7 +2,6 @@ import assert from 'node:assert'; import { describe, it } from 'node:test'; import { buildTddDependencyOps, - resolveTddPaths, resolveTddWorkingDirectory, } from '../../src/tdd/runtime-context.js'; @@ -70,16 +69,4 @@ describe('tdd/runtime-context', () => { ); assert.strictEqual(logged, 'Invalid working directory: bad path'); }); - - it('delegates path initialization', () => { - let paths = resolveTddPaths('/tmp/work', dir => ({ - baselinePath: `${dir}/.vizzly/baselines`, - currentPath: `${dir}/.vizzly/current`, - diffPath: `${dir}/.vizzly/diffs`, - })); - - assert.strictEqual(paths.baselinePath, '/tmp/work/.vizzly/baselines'); - assert.strictEqual(paths.currentPath, '/tmp/work/.vizzly/current'); - assert.strictEqual(paths.diffPath, '/tmp/work/.vizzly/diffs'); - }); }); diff --git a/tests/tdd/tdd-service.test.js b/tests/tdd/tdd-service.test.js index 9f15d063..2a8c68cd 100644 --- a/tests/tdd/tdd-service.test.js +++ b/tests/tdd/tdd-service.test.js @@ -1,6 +1,6 @@ import assert from 'node:assert'; import { describe, it } from 'node:test'; -import { createTDDService, TddService } from '../../src/tdd/tdd-service.js'; +import { TddService } from '../../src/tdd/tdd-service.js'; /** * Create mock output for testing @@ -177,92 +177,6 @@ function createMockDeps(overrides = {}) { } describe('tdd/tdd-service', () => { - describe('createTDDService', () => { - it('creates TddService instance with default options', () => { - let mockDeps = createMockDeps(); - let service = createTDDService({}, {}, mockDeps); - - assert.ok(service instanceof TddService); - }); - - it('passes options to TddService constructor', () => { - let mockDeps = createMockDeps(); - let service = createTDDService( - { comparison: { threshold: 5.0 } }, - { workingDir: '/custom', setBaseline: true }, - mockDeps - ); - - assert.strictEqual(service.setBaseline, true); - assert.strictEqual(service.workingDir, '/custom'); - assert.strictEqual(service.threshold, 5.0); - }); - }); - - describe('TddService constructor', () => { - it('initializes with default values', () => { - let mockDeps = createMockDeps(); - let service = new TddService({}, process.cwd(), false, null, mockDeps); - - assert.strictEqual(service.threshold, 2.0); - assert.strictEqual(service.minClusterSize, 2); - assert.deepStrictEqual(service.signatureProperties, []); - assert.strictEqual(service.comparisons.length, 0); - }); - - it('uses config values when provided', () => { - let mockDeps = createMockDeps(); - let config = { - comparison: { threshold: 3.5, minClusterSize: 5 }, - signatureProperties: ['browser', 'viewport'], - }; - let service = new TddService(config, '/test', false, null, mockDeps); - - assert.strictEqual(service.threshold, 3.5); - assert.strictEqual(service.minClusterSize, 5); - assert.deepStrictEqual(service.signatureProperties, [ - 'browser', - 'viewport', - ]); - }); - - it('preserves exact-match threshold from config', () => { - let mockDeps = createMockDeps(); - let service = new TddService( - { comparison: { threshold: 0 } }, - '/test', - false, - null, - mockDeps - ); - - assert.strictEqual(service.threshold, 0); - }); - - it('outputs baseline update mode message when setBaseline is true', () => { - let mockDeps = createMockDeps(); - new TddService({}, '/test', true, null, mockDeps); - - let infoCall = mockDeps.output.calls.find( - c => c.method === 'info' && c.args[0].includes('Baseline update mode') - ); - assert.ok(infoCall); - }); - - it('throws error for invalid working directory', () => { - let mockDeps = createMockDeps({ - validatePathSecurity: () => { - throw new Error('Invalid path'); - }, - }); - - assert.throws( - () => new TddService({}, '/invalid', false, null, mockDeps), - /Working directory validation failed/ - ); - }); - }); - describe('loadBaseline', () => { it('returns null in setBaseline mode', async () => { let mockDeps = createMockDeps(); @@ -271,10 +185,6 @@ describe('tdd/tdd-service', () => { let result = await service.loadBaseline(); assert.strictEqual(result, null); - let debugCall = mockDeps.output.calls.find( - c => c.method === 'debug' && c.args[1]?.includes('skipping loading') - ); - assert.ok(debugCall); }); it('returns null when no metadata exists', async () => { @@ -343,43 +253,6 @@ describe('tdd/tdd-service', () => { assert.strictEqual(service.baselineData, null); }); - it('returns null and logs message when no baseline exists (no API key)', async () => { - let mockDeps = createMockDeps({ - metadata: { loadBaselineMetadata: () => null }, - }); - let service = new TddService({}, '/test', false, null, mockDeps); - - let result = await service.handleLocalBaselines(); - - assert.strictEqual(result, null); - let infoCall = mockDeps.output.calls.find( - c => - c.method === 'info' && c.args[0].includes('No local baseline found') - ); - assert.ok(infoCall); - }); - - it('returns null and logs different message when no baseline exists (with API key)', async () => { - let mockDeps = createMockDeps({ - metadata: { loadBaselineMetadata: () => null }, - }); - let service = new TddService( - { apiKey: 'test-key' }, - '/test', - false, - null, - mockDeps - ); - - let result = await service.handleLocalBaselines(); - - assert.strictEqual(result, null); - let infoCall = mockDeps.output.calls.find( - c => c.method === 'info' && c.args[0].includes('API key available') - ); - assert.ok(infoCall); - }); - it('returns baseline when it exists', async () => { let metadata = { buildId: 'build-123', @@ -397,53 +270,7 @@ describe('tdd/tdd-service', () => { }); }); - describe('_upsertComparison', () => { - it('replaces existing comparison when ID matches', () => { - let mockDeps = createMockDeps(); - let service = new TddService({}, '/test', false, null, mockDeps); - - // Add initial comparison - service.comparisons = [ - { - id: 'comp-1', - name: 'homepage', - status: 'failed', - diffPercentage: 5.0, - }, - { id: 'comp-2', name: 'button', status: 'passed' }, - ]; - - // Upsert with same ID but different status - service._upsertComparison({ - id: 'comp-1', - name: 'homepage', - status: 'passed', - diffPercentage: 0, - }); - - assert.strictEqual(service.comparisons.length, 2); - assert.strictEqual(service.comparisons[0].status, 'passed'); - assert.strictEqual(service.comparisons[0].diffPercentage, 0); - }); - - it('appends new comparison when ID does not exist', () => { - let mockDeps = createMockDeps(); - let service = new TddService({}, '/test', false, null, mockDeps); - - service.comparisons = [ - { id: 'comp-1', name: 'homepage', status: 'passed' }, - ]; - - service._upsertComparison({ - id: 'comp-2', - name: 'button', - status: 'new', - }); - - assert.strictEqual(service.comparisons.length, 2); - assert.strictEqual(service.comparisons[1].id, 'comp-2'); - }); - + describe('daemon result lifecycle', () => { it('prevents stale results from accumulating in daemon mode', async () => { // This test verifies the fix for issue #158 // In daemon mode, re-running tests should replace old results, not accumulate them @@ -603,11 +430,6 @@ describe('tdd/tdd-service', () => { assert.strictEqual(result.error, "Image dimensions don't match"); assert.strictEqual(baselineSaved, false); assert.strictEqual(metadataSaved, false); - // Dimension mismatch now logs at debug level (shown with --verbose) - let debugCall = mockDeps.output.calls.find( - c => c.method === 'debug' && c.args[1]?.includes('dimension mismatch') - ); - assert.ok(debugCall); }); it('returns error comparison on unexpected error', async () => { @@ -659,7 +481,6 @@ describe('tdd/tdd-service', () => { }); let service = new TddService({}, '/test', false, null, mockDeps); - // Should not throw, just log warning let result = await service.compareScreenshot( 'homepage', Buffer.from('test'), @@ -667,12 +488,6 @@ describe('tdd/tdd-service', () => { ); assert.strictEqual(result.status, 'new'); - let warnCall = mockDeps.output.calls.find( - c => - c.method === 'warn' && - c.args[0].includes('Property validation failed') - ); - assert.ok(warnCall); }); it('uses per-screenshot threshold when provided', async () => { @@ -724,132 +539,6 @@ describe('tdd/tdd-service', () => { }); }); - describe('getResults', () => { - it('returns results summary from buildResults', () => { - let mockDeps = createMockDeps(); - let service = new TddService({}, '/test', false, null, mockDeps); - - // Add some comparisons - service.comparisons = [ - { id: '1', status: 'passed', name: 'test1' }, - { id: '2', status: 'failed', name: 'test2' }, - { id: '3', status: 'new', name: 'test3' }, - ]; - - let results = service.getResults(); - - assert.strictEqual(results.total, 3); - assert.strictEqual(results.passed, 1); - assert.strictEqual(results.failed, 1); - assert.strictEqual(results.new, 1); - }); - }); - - describe('printResults', () => { - it('prints summary header with screenshot count', async () => { - let mockOutput = createMockOutput(); - let mockDeps = createMockDeps({ output: mockOutput }); - let service = new TddService({}, '/test', false, null, mockDeps); - - service.comparisons = [ - { id: '1', status: 'passed', name: 'test1' }, - { id: '2', status: 'passed', name: 'test2' }, - ]; - - await service.printResults(); - - // Should have printed the header - let printCalls = mockOutput.calls.filter(c => c.method === 'print'); - let headerCall = printCalls.find(c => - c.args[0]?.includes('2 screenshots compared') - ); - assert.ok(headerCall, 'Should print header with screenshot count'); - }); - - it('prints passed count in default mode', async () => { - let mockOutput = createMockOutput(); - let mockDeps = createMockDeps({ output: mockOutput }); - let service = new TddService({}, '/test', false, null, mockDeps); - - service.comparisons = [ - { id: '1', status: 'passed', name: 'test1' }, - { id: '2', status: 'passed', name: 'test2' }, - ]; - - await service.printResults(); - - let printCalls = mockOutput.calls.filter(c => c.method === 'print'); - let passedCall = printCalls.find(c => c.args[0]?.includes('2 passed')); - assert.ok(passedCall, 'Should print passed count'); - }); - - it('prints failed comparisons', async () => { - let mockOutput = createMockOutput(); - let mockDeps = createMockDeps({ output: mockOutput }); - let service = new TddService({}, '/test', false, null, mockDeps); - - service.comparisons = [ - { id: '1', status: 'passed', name: 'test1' }, - { id: '2', status: 'failed', name: 'failed-test', diffPercentage: 1.5 }, - ]; - - await service.printResults(); - - let printCalls = mockOutput.calls.filter(c => c.method === 'print'); - let failedHeaderCall = printCalls.find(c => - c.args[0]?.includes('1 visual change') - ); - let failedNameCall = printCalls.find(c => - c.args[0]?.includes('failed-test') - ); - assert.ok(failedHeaderCall, 'Should print visual changes header'); - assert.ok(failedNameCall, 'Should print failed comparison name'); - }); - - it('prints start command hint when there are changes', async () => { - let mockOutput = createMockOutput(); - let mockDeps = createMockDeps({ output: mockOutput }); - let service = new TddService( - { server: { port: 47392 } }, - '/test', - false, - null, - mockDeps - ); - - service.comparisons = [ - { id: '1', status: 'failed', name: 'failed-test' }, - ]; - - await service.printResults(); - - let printCalls = mockOutput.calls.filter(c => c.method === 'print'); - let hintCall = printCalls.find(c => - c.args[0]?.includes('vizzly tdd start --open') - ); - assert.ok(hintCall, 'Should print tdd start command hint'); - }); - - it('does not print start command hint when all passed', async () => { - let mockOutput = createMockOutput(); - let mockDeps = createMockDeps({ output: mockOutput }); - let service = new TddService({}, '/test', false, null, mockDeps); - - service.comparisons = [{ id: '1', status: 'passed', name: 'test1' }]; - - await service.printResults(); - - let printCalls = mockOutput.calls.filter(c => c.method === 'print'); - let hintCall = printCalls.find(c => - c.args[0]?.includes('vizzly tdd start --open') - ); - assert.ok( - !hintCall, - 'Should NOT print start command hint when all passed' - ); - }); - }); - describe('getHotspotForScreenshot', () => { it('returns hotspot from memory cache', () => { let mockDeps = createMockDeps(); @@ -895,37 +584,6 @@ describe('tdd/tdd-service', () => { }); }); - describe('loadHotspots', () => { - it('delegates to loadHotspotMetadata', () => { - let hotspotData = { homepage: { regions: [] } }; - let mockDeps = createMockDeps({ - metadata: { loadHotspotMetadata: () => hotspotData }, - }); - let service = new TddService({}, '/test', false, null, mockDeps); - - let result = service.loadHotspots(); - - assert.deepStrictEqual(result, hotspotData); - }); - }); - - describe('calculateHotspotCoverage', () => { - it('delegates to calculateHotspotCoverage function', () => { - let mockDeps = createMockDeps({ - calculateHotspotCoverage: () => ({ - coverage: 0.85, - linesInHotspots: 85, - totalLines: 100, - }), - }); - let service = new TddService({}, '/test', false, null, mockDeps); - - let result = service.calculateHotspotCoverage([], {}); - - assert.strictEqual(result.coverage, 0.85); - }); - }); - describe('acceptBaseline', () => { it('accepts a comparison by ID and creates baseline', async () => { let mockDeps = createMockDeps({ @@ -1115,10 +773,6 @@ describe('tdd/tdd-service', () => { let count = service.updateBaselines(); assert.strictEqual(count, 0); - let warnCall = mockDeps.output.calls.find( - c => c.method === 'warn' && c.args[0].includes('No comparisons found') - ); - assert.ok(warnCall); }); it('skips comparisons with missing current screenshot', () => { @@ -1141,12 +795,6 @@ describe('tdd/tdd-service', () => { let count = service.updateBaselines(); assert.strictEqual(count, 0); - let warnCall = mockDeps.output.calls.find( - c => - c.method === 'warn' && - c.args[0].includes('Current screenshot not found') - ); - assert.ok(warnCall); }); it('creates baseline metadata if it does not exist', () => { @@ -1198,28 +846,6 @@ describe('tdd/tdd-service', () => { }); describe('downloadBaselines', () => { - it('uses injected getDefaultBranch when branch is not specified', async () => { - let getDefaultBranchCalled = false; - let mockDeps = createMockDeps({ - api: { - getDefaultBranch: async () => { - getDefaultBranchCalled = true; - return 'main'; - }, - getBuilds: async () => ({ data: [] }), - }, - }); - let service = new TddService({}, '/test', false, null, mockDeps); - - // Should not throw "getDefaultBranch is not defined" - await service.downloadBaselines('test', null, null, null); - - assert.ok( - getDefaultBranchCalled, - 'Should call injected getDefaultBranch' - ); - }); - it('uses injected dependencies when downloading by buildId', async () => { let apiCalls = []; let mockDeps = createMockDeps({ @@ -1546,56 +1172,9 @@ describe('tdd/tdd-service', () => { assert.strictEqual(result.properties.viewport_width, 1440); assert.strictEqual(result.properties.device, 'desktop'); }); - - it('outputs info message when baseline is created', () => { - let mockDeps = createMockDeps({ - fs: { writeFileSync: () => {} }, - }); - let service = new TddService({}, '/test', false, null, mockDeps); - - service.createNewBaseline( - 'hero', - Buffer.from('image-data'), - {}, - '/test/.vizzly/current/hero.png', - '/test/.vizzly/baselines/hero.png' - ); - - let infoCall = mockDeps.output.calls.find( - c => c.method === 'info' && c.args[0].includes('Creating baseline') - ); - assert.ok(infoCall); - - let successCall = mockDeps.output.calls.find( - c => c.method === 'info' && c.args[0].includes('Baseline created') - ); - assert.ok(successCall); - }); }); describe('processDownloadedBaselines', () => { - it('clears local baseline data before processing', async () => { - let clearCalled = false; - let mockDeps = createMockDeps({ - baseline: { - clearBaselineData: () => { - clearCalled = true; - }, - }, - }); - let service = new TddService({}, '/test', false, null, mockDeps); - - let apiResponse = { - build: { id: 'build-1', name: 'Test Build', status: 'completed' }, - screenshots: [], - signatureProperties: [], - }; - - await service.processDownloadedBaselines(apiResponse, 'build-1'); - - assert.ok(clearCalled, 'Should clear baseline data'); - }); - it('extracts and stores signature properties from API response', async () => { let mockDeps = createMockDeps({ baseline: { clearBaselineData: () => {} }, @@ -1616,49 +1195,6 @@ describe('tdd/tdd-service', () => { ]); }); - it('returns null and falls back to local baselines when build status is failed', async () => { - let handleLocalBaselinesCalled = false; - let mockDeps = createMockDeps({ - baseline: { clearBaselineData: () => {} }, - metadata: { - loadBaselineMetadata: () => { - handleLocalBaselinesCalled = true; - return null; - }, - }, - }); - let service = new TddService({}, '/test', false, null, mockDeps); - - let apiResponse = { - build: { id: 'build-1', name: 'Test Build', status: 'failed' }, - screenshots: [], - }; - - await service.processDownloadedBaselines(apiResponse, 'build-1'); - - // handleLocalBaselines loads baseline metadata - assert.ok(handleLocalBaselinesCalled); - }); - - it('warns when build status is not completed', async () => { - let mockDeps = createMockDeps({ - baseline: { clearBaselineData: () => {} }, - }); - let service = new TddService({}, '/test', false, null, mockDeps); - - let apiResponse = { - build: { id: 'build-1', name: 'Test Build', status: 'pending' }, - screenshots: [], - }; - - await service.processDownloadedBaselines(apiResponse, 'build-1'); - - let warnCall = mockDeps.output.calls.find( - c => c.method === 'warn' && c.args[0].includes('pending') - ); - assert.ok(warnCall, 'Should warn about non-completed status'); - }); - it('returns null when no screenshots in build', async () => { let mockDeps = createMockDeps({ baseline: { clearBaselineData: () => {} }, @@ -1678,27 +1214,6 @@ describe('tdd/tdd-service', () => { assert.strictEqual(result, null); }); - it('skips screenshots without filename', async () => { - let mockDeps = createMockDeps({ - baseline: { clearBaselineData: () => {} }, - }); - let service = new TddService({}, '/test', false, null, mockDeps); - - let apiResponse = { - build: { id: 'build-1', name: 'Test Build', status: 'completed' }, - screenshots: [ - { name: 'test', original_url: 'http://example.com/1.png' }, - ], - }; - - await service.processDownloadedBaselines(apiResponse, 'build-1'); - - let warnCall = mockDeps.output.calls.find( - c => c.method === 'warn' && c.args[0].includes('no filename') - ); - assert.ok(warnCall, 'Should warn about missing filename'); - }); - it('skips download when SHA matches existing file', async () => { let fetchCalled = false; let mockDeps = createMockDeps({ @@ -1784,104 +1299,6 @@ describe('tdd/tdd-service', () => { assert.strictEqual(writtenFiles.length, 2); // screenshot + metadata }); - it('skips screenshots without download URL', async () => { - let mockDeps = createMockDeps({ - baseline: { clearBaselineData: () => {} }, - fs: { existsSync: () => false }, - }); - let service = new TddService({}, '/test', false, null, mockDeps); - - let apiResponse = { - build: { id: 'build-1', name: 'Test Build', status: 'completed' }, - screenshots: [{ name: 'test', filename: 'test.png' }], - }; - - await service.processDownloadedBaselines(apiResponse, 'build-1'); - - let warnCall = mockDeps.output.calls.find( - c => c.method === 'warn' && c.args[0].includes('no download URL') - ); - assert.ok(warnCall, 'Should warn about missing download URL'); - }); - - it('handles download failures gracefully', async () => { - let mockDeps = createMockDeps({ - baseline: { clearBaselineData: () => {} }, - fs: { - existsSync: () => false, - writeFileSync: () => {}, - }, - metadata: { - loadBaselineMetadata: () => null, - saveBaselineMetadata: () => {}, - }, - api: { - fetchWithTimeout: async () => { - throw new Error('Network error'); - }, - }, - }); - let service = new TddService({}, '/test', false, null, mockDeps); - - let apiResponse = { - build: { id: 'build-1', name: 'Test Build', status: 'completed' }, - screenshots: [ - { - name: 'test', - filename: 'test.png', - original_url: 'http://example.com/1.png', - }, - ], - }; - - // Should not throw - await service.processDownloadedBaselines(apiResponse, 'build-1'); - - let warnCall = mockDeps.output.calls.find( - c => c.method === 'warn' && c.args[0].includes('Failed to download') - ); - assert.ok(warnCall, 'Should warn about download failure'); - }); - - it('handles non-ok HTTP responses', async () => { - let mockDeps = createMockDeps({ - baseline: { clearBaselineData: () => {} }, - fs: { - existsSync: () => false, - writeFileSync: () => {}, - }, - metadata: { - loadBaselineMetadata: () => null, - saveBaselineMetadata: () => {}, - }, - api: { - fetchWithTimeout: async () => ({ - ok: false, - statusText: 'Not Found', - }), - }, - }); - let service = new TddService({}, '/test', false, null, mockDeps); - - let apiResponse = { - build: { id: 'build-1', name: 'Test Build', status: 'completed' }, - screenshots: [ - { - name: 'test', - filename: 'test.png', - original_url: 'http://example.com/1.png', - }, - ], - }; - - await service.processDownloadedBaselines(apiResponse, 'build-1'); - - let warnCall = mockDeps.output.calls.find( - c => c.method === 'warn' && c.args[0].includes('Failed to download') - ); - assert.ok(warnCall, 'Should warn about HTTP error'); - }); - it('saves baseline metadata after successful downloads', async () => { let savedMetadata = null; let mockDeps = createMockDeps({ @@ -2207,70 +1624,9 @@ describe('tdd/tdd-service', () => { assert.strictEqual(metadata.buildId, 'build-1'); assert.strictEqual(metadata.commitSha, 'abc123'); }); - - it('logs summary with download counts', async () => { - let mockDeps = createMockDeps({ - baseline: { clearBaselineData: () => {} }, - fs: { - existsSync: () => false, - writeFileSync: () => {}, - }, - metadata: { - loadBaselineMetadata: () => null, - saveBaselineMetadata: () => {}, - }, - api: { - fetchWithTimeout: async () => ({ - ok: true, - arrayBuffer: async () => new ArrayBuffer(10), - }), - }, - }); - let service = new TddService({}, '/test', false, null, mockDeps); - - let apiResponse = { - build: { id: 'build-1', name: 'Test Build', status: 'completed' }, - screenshots: [ - { - name: 'test1', - filename: 'test1.png', - original_url: 'http://example.com/1.png', - }, - { - name: 'test2', - filename: 'test2.png', - original_url: 'http://example.com/2.png', - }, - ], - }; - - await service.processDownloadedBaselines(apiResponse, 'build-1'); - - let summaryCall = mockDeps.output.calls.find( - c => c.method === 'info' && c.args[0].includes('Downloaded') - ); - assert.ok(summaryCall, 'Should log download summary'); - }); }); describe('downloadHotspots', () => { - it('skips download when no API key configured', async () => { - let apiCalled = false; - let mockDeps = createMockDeps({ - api: { - getBatchHotspots: async () => { - apiCalled = true; - return { hotspots: {} }; - }, - }, - }); - let service = new TddService({}, '/test', false, null, mockDeps); - - await service.downloadHotspots([{ name: 'test' }]); - - assert.ok(!apiCalled, 'Should not call API without apiKey'); - }); - it('fetches hotspots for unique screenshot names', async () => { let requestedNames = null; let mockDeps = createMockDeps({ @@ -2330,54 +1686,5 @@ describe('tdd/tdd-service', () => { { y1: 0, y2: 100 }, ]); }); - - it('handles API errors gracefully', async () => { - let mockDeps = createMockDeps({ - api: { - getBatchHotspots: async () => { - throw new Error('API unavailable'); - }, - }, - }); - let service = new TddService( - { apiKey: 'test-key' }, - '/test', - false, - null, - mockDeps - ); - - // Should not throw - await service.downloadHotspots([{ name: 'test' }]); - - let warnCall = mockDeps.output.calls.find( - c => - c.method === 'warn' && c.args[0].includes('Could not fetch hotspot') - ); - assert.ok(warnCall, 'Should warn about API failure'); - }); - - it('skips when no screenshots provided', async () => { - let apiCalled = false; - let mockDeps = createMockDeps({ - api: { - getBatchHotspots: async () => { - apiCalled = true; - return { hotspots: {} }; - }, - }, - }); - let service = new TddService( - { apiKey: 'test-key' }, - '/test', - false, - null, - mockDeps - ); - - await service.downloadHotspots([]); - - assert.ok(!apiCalled, 'Should not call API with empty screenshots'); - }); }); }); diff --git a/tests/unit/ci-env.test.js b/tests/unit/ci-env.test.js deleted file mode 100644 index fcc18e18..00000000 --- a/tests/unit/ci-env.test.js +++ /dev/null @@ -1,391 +0,0 @@ -import assert from 'node:assert'; -import { mkdtempSync, unlinkSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterEach, beforeEach, describe, it } from 'node:test'; -import { - getBranch, - getCIProvider, - getCommit, - getCommitMessage, - getGitHubEvent, - getPullRequestBaseSha, - getPullRequestHeadSha, - getPullRequestNumber, - resetGitHubEventCache, -} from '../../src/utils/ci-env.js'; - -describe('CI Environment Detection', () => { - let originalEnv; - // Track temp files for cleanup in case assertions fail before unlinkSync - let tempFilesToCleanup = []; - - beforeEach(() => { - originalEnv = { ...process.env }; - tempFilesToCleanup = []; - // Clear only CI-related environment variables - let ciVars = [ - 'VIZZLY_BRANCH', - 'VIZZLY_COMMIT_SHA', - 'VIZZLY_COMMIT_MESSAGE', - 'VIZZLY_PR_NUMBER', - 'VIZZLY_PR_HEAD_SHA', - 'VIZZLY_PR_BASE_SHA', - 'GITHUB_ACTIONS', - 'GITHUB_HEAD_REF', - 'GITHUB_REF_NAME', - 'GITHUB_SHA', - 'GITHUB_EVENT_NAME', - 'GITHUB_EVENT_PATH', - 'GITHUB_REF', - 'GITLAB_CI', - 'CI_COMMIT_REF_NAME', - 'CI_COMMIT_SHA', - 'CI_COMMIT_MESSAGE', - 'CI_MERGE_REQUEST_ID', - 'CIRCLECI', - 'CIRCLE_PULL_REQUEST', - 'TRAVIS', - 'TRAVIS_PULL_REQUEST', - ]; - for (let key of ciVars) { - delete process.env[key]; - } - // Reset the GitHub event cache between tests - resetGitHubEventCache(); - }); - - afterEach(() => { - // Cleanup any temp files that weren't cleaned up (e.g., if assertion failed) - for (let file of tempFilesToCleanup) { - try { - unlinkSync(file); - } catch { - // File already cleaned up or doesn't exist - } - } - // Restore original environment - Object.keys(process.env).forEach(key => { - if (!(key in originalEnv)) { - delete process.env[key]; - } - }); - Object.assign(process.env, originalEnv); - }); - - /** - * Helper to create a temp event file and track it for cleanup - */ - function createTempEventFile(payload) { - let tempDir = mkdtempSync(join(tmpdir(), 'vizzly-test-')); - let eventPath = join(tempDir, 'event.json'); - writeFileSync( - eventPath, - typeof payload === 'string' ? payload : JSON.stringify(payload) - ); - tempFilesToCleanup.push(eventPath); - return eventPath; - } - - describe('getBranch', () => { - it('should return VIZZLY_BRANCH when set', () => { - process.env.VIZZLY_BRANCH = 'vizzly-branch'; - process.env.GITHUB_HEAD_REF = 'github-branch'; - - assert.strictEqual(getBranch(), 'vizzly-branch'); - }); - - it('should return GitHub Actions branch for PRs', () => { - process.env.GITHUB_HEAD_REF = 'feature/new-ui'; - - assert.strictEqual(getBranch(), 'feature/new-ui'); - }); - - it('should return GitHub Actions branch for pushes', () => { - process.env.GITHUB_REF_NAME = 'main'; - - assert.strictEqual(getBranch(), 'main'); - }); - - it('should return GitLab CI branch', () => { - process.env.CI_COMMIT_REF_NAME = 'develop'; - - assert.strictEqual(getBranch(), 'develop'); - }); - - it('should return null when no branch detected', () => { - assert.strictEqual(getBranch(), null); - }); - }); - - describe('getCommit', () => { - it('should return VIZZLY_COMMIT_SHA when set', () => { - process.env.VIZZLY_COMMIT_SHA = 'vizzly-commit'; - process.env.GITHUB_SHA = 'github-commit'; - - assert.strictEqual(getCommit(), 'vizzly-commit'); - }); - - it('should return GitHub Actions commit for push events', () => { - process.env.GITHUB_ACTIONS = 'true'; - process.env.GITHUB_SHA = 'abc123def456'; - - assert.strictEqual(getCommit(), 'abc123def456'); - }); - - it('should return PR head SHA from event file for GitHub Actions pull_request events', () => { - // Create a temporary event file simulating GitHub Actions pull_request event - let eventPath = createTempEventFile({ - pull_request: { - number: 123, - head: { - sha: 'pr-head-sha-abc123', - ref: 'feature/my-branch', - }, - base: { - sha: 'base-sha-def456', - ref: 'main', - }, - }, - }); - - process.env.GITHUB_ACTIONS = 'true'; - process.env.GITHUB_EVENT_PATH = eventPath; - process.env.GITHUB_SHA = 'merge-commit-sha-wrong'; // This is the merge commit (wrong!) - - // Reset cache to pick up new event file - resetGitHubEventCache(); - - assert.strictEqual(getCommit(), 'pr-head-sha-abc123'); - }); - - it('should fall back to GITHUB_SHA when event file is missing', () => { - process.env.GITHUB_ACTIONS = 'true'; - process.env.GITHUB_EVENT_PATH = '/nonexistent/path/event.json'; - process.env.GITHUB_SHA = 'fallback-sha'; - - resetGitHubEventCache(); - - assert.strictEqual(getCommit(), 'fallback-sha'); - }); - - it('should fall back to GITHUB_SHA when event file has no pull_request', () => { - // Push event - no pull_request object - let eventPath = createTempEventFile({ ref: 'refs/heads/main' }); - - process.env.GITHUB_ACTIONS = 'true'; - process.env.GITHUB_EVENT_PATH = eventPath; - process.env.GITHUB_SHA = 'push-commit-sha'; - - resetGitHubEventCache(); - - assert.strictEqual(getCommit(), 'push-commit-sha'); - }); - - it('should return GitLab CI commit', () => { - process.env.CI_COMMIT_SHA = 'def456ghi789'; - - assert.strictEqual(getCommit(), 'def456ghi789'); - }); - - it('should return null when no commit detected', () => { - assert.strictEqual(getCommit(), null); - }); - }); - - describe('getPullRequestHeadSha', () => { - it('should return VIZZLY_PR_HEAD_SHA when set', () => { - process.env.VIZZLY_PR_HEAD_SHA = 'vizzly-head-sha'; - process.env.GITHUB_SHA = 'github-sha'; - - assert.strictEqual(getPullRequestHeadSha(), 'vizzly-head-sha'); - }); - - it('should return PR head SHA from event file for GitHub Actions', () => { - let eventPath = createTempEventFile({ - pull_request: { - head: { sha: 'pr-head-sha-from-event' }, - base: { sha: 'base-sha' }, - }, - }); - - process.env.GITHUB_ACTIONS = 'true'; - process.env.GITHUB_EVENT_PATH = eventPath; - process.env.GITHUB_SHA = 'merge-commit-sha'; - - resetGitHubEventCache(); - - assert.strictEqual(getPullRequestHeadSha(), 'pr-head-sha-from-event'); - }); - }); - - describe('getPullRequestBaseSha', () => { - it('should return VIZZLY_PR_BASE_SHA when set', () => { - process.env.VIZZLY_PR_BASE_SHA = 'vizzly-base-sha'; - - assert.strictEqual(getPullRequestBaseSha(), 'vizzly-base-sha'); - }); - - it('should return PR base SHA from event file for GitHub Actions', () => { - let eventPath = createTempEventFile({ - pull_request: { - head: { sha: 'head-sha' }, - base: { sha: 'base-sha-from-event' }, - }, - }); - - process.env.GITHUB_ACTIONS = 'true'; - process.env.GITHUB_EVENT_PATH = eventPath; - - resetGitHubEventCache(); - - assert.strictEqual(getPullRequestBaseSha(), 'base-sha-from-event'); - }); - - it('should return null when not in PR context', () => { - assert.strictEqual(getPullRequestBaseSha(), null); - }); - }); - - describe('getGitHubEvent', () => { - it('should return empty object when GITHUB_EVENT_PATH is not set', () => { - assert.deepStrictEqual(getGitHubEvent(), {}); - }); - - it('should parse and cache event file', () => { - let eventPath = createTempEventFile({ action: 'opened', number: 42 }); - - process.env.GITHUB_EVENT_PATH = eventPath; - resetGitHubEventCache(); - - let event = getGitHubEvent(); - assert.strictEqual(event.action, 'opened'); - assert.strictEqual(event.number, 42); - - // Should return cached value even after file is cleaned up - unlinkSync(eventPath); - // Remove from cleanup list since we manually deleted it - tempFilesToCleanup = tempFilesToCleanup.filter(f => f !== eventPath); - - let cachedEvent = getGitHubEvent(); - assert.strictEqual(cachedEvent.action, 'opened'); - }); - - it('should return empty object for invalid JSON', () => { - let eventPath = createTempEventFile('not valid json {{{'); - - process.env.GITHUB_EVENT_PATH = eventPath; - resetGitHubEventCache(); - - assert.deepStrictEqual(getGitHubEvent(), {}); - }); - }); - - describe('getCommitMessage', () => { - it('should return VIZZLY_COMMIT_MESSAGE when set', () => { - process.env.VIZZLY_COMMIT_MESSAGE = 'Vizzly commit message'; - process.env.CI_COMMIT_MESSAGE = 'CI commit message'; - - assert.strictEqual(getCommitMessage(), 'Vizzly commit message'); - }); - - it('should return GitLab CI commit message', () => { - process.env.CI_COMMIT_MESSAGE = 'Fix: Update component'; - - assert.strictEqual(getCommitMessage(), 'Fix: Update component'); - }); - - it('should return null when no commit message detected', () => { - assert.strictEqual(getCommitMessage(), null); - }); - }); - - describe('getPullRequestNumber', () => { - it('should return VIZZLY_PR_NUMBER when set', () => { - process.env.VIZZLY_PR_NUMBER = '999'; - process.env.CI_MERGE_REQUEST_ID = '123'; - - assert.strictEqual(getPullRequestNumber(), 999); - }); - - it('should ignore malformed VIZZLY_PR_NUMBER values', () => { - process.env.VIZZLY_PR_NUMBER = '999abc'; - process.env.CI_MERGE_REQUEST_ID = '123'; - - assert.strictEqual(getPullRequestNumber(), null); - }); - - it('should return GitHub Actions PR number', () => { - process.env.GITHUB_ACTIONS = 'true'; - process.env.GITHUB_EVENT_NAME = 'pull_request'; - process.env.GITHUB_REF = 'refs/pull/456/merge'; - - assert.strictEqual(getPullRequestNumber(), 456); - }); - - it('should return GitLab CI merge request ID', () => { - process.env.CI_MERGE_REQUEST_ID = '789'; - - assert.strictEqual(getPullRequestNumber(), 789); - }); - - it('should ignore decimal GitLab CI merge request IDs', () => { - process.env.CI_MERGE_REQUEST_ID = '789.5'; - - assert.strictEqual(getPullRequestNumber(), null); - }); - - it('should return CircleCI PR number from URL', () => { - process.env.CIRCLE_PULL_REQUEST = - 'https://github.com/owner/repo/pull/321'; - - assert.strictEqual(getPullRequestNumber(), 321); - }); - - it('should return Travis CI PR number', () => { - process.env.TRAVIS_PULL_REQUEST = '654'; - - assert.strictEqual(getPullRequestNumber(), 654); - }); - - it('should return null for Travis CI when not a PR', () => { - process.env.TRAVIS_PULL_REQUEST = 'false'; - - assert.strictEqual(getPullRequestNumber(), null); - }); - - it('should return null when no PR detected', () => { - assert.strictEqual(getPullRequestNumber(), null); - }); - }); - - describe('getCIProvider', () => { - it('should detect GitHub Actions', () => { - process.env.GITHUB_ACTIONS = 'true'; - - assert.strictEqual(getCIProvider(), 'github-actions'); - }); - - it('should detect GitLab CI', () => { - process.env.GITLAB_CI = 'true'; - - assert.strictEqual(getCIProvider(), 'gitlab-ci'); - }); - - it('should detect CircleCI', () => { - process.env.CIRCLECI = 'true'; - - assert.strictEqual(getCIProvider(), 'circleci'); - }); - - it('should detect Travis CI', () => { - process.env.TRAVIS = 'true'; - - assert.strictEqual(getCIProvider(), 'travis-ci'); - }); - - it('should return unknown when no CI detected', () => { - assert.strictEqual(getCIProvider(), 'unknown'); - }); - }); -}); diff --git a/tests/utils/ci-env.test.js b/tests/utils/ci-env.test.js index b5f3682e..85f8b95f 100644 --- a/tests/utils/ci-env.test.js +++ b/tests/utils/ci-env.test.js @@ -1,10 +1,14 @@ import assert from 'node:assert'; +import { mkdtempSync, unlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { afterEach, beforeEach, describe, it } from 'node:test'; import { getBranch, getCIProvider, getCommit, getCommitMessage, + getGitHubEvent, getPullRequestBaseRef, getPullRequestBaseSha, getPullRequestHeadRef, @@ -16,9 +20,11 @@ import { describe('utils/ci-env', () => { let originalEnv; + let tempFilesToCleanup = []; beforeEach(() => { originalEnv = { ...process.env }; + tempFilesToCleanup = []; // Clear all CI-related env vars let ciVars = [ 'VIZZLY_BRANCH', @@ -116,9 +122,27 @@ describe('utils/ci-env', () => { }); afterEach(() => { + for (let file of tempFilesToCleanup) { + try { + unlinkSync(file); + } catch { + // File was already cleaned up or does not exist. + } + } process.env = originalEnv; }); + function createTempEventFile(payload) { + let tempDir = mkdtempSync(join(tmpdir(), 'vizzly-test-')); + let eventPath = join(tempDir, 'event.json'); + writeFileSync( + eventPath, + typeof payload === 'string' ? payload : JSON.stringify(payload) + ); + tempFilesToCleanup.push(eventPath); + return eventPath; + } + describe('getBranch', () => { it('returns null when no CI env vars set', () => { assert.strictEqual(getBranch(), null); @@ -187,6 +211,45 @@ describe('utils/ci-env', () => { assert.strictEqual(getCommit(), 'abc123'); }); + it('reads the PR head SHA from a GitHub Actions event file', () => { + let eventPath = createTempEventFile({ + pull_request: { + head: { sha: 'pr-head-sha-abc123' }, + base: { sha: 'base-sha-def456' }, + }, + }); + + process.env.GITHUB_ACTIONS = 'true'; + process.env.GITHUB_EVENT_PATH = eventPath; + process.env.GITHUB_SHA = 'merge-commit-sha'; + + resetGitHubEventCache(); + + assert.strictEqual(getCommit(), 'pr-head-sha-abc123'); + }); + + it('falls back to GITHUB_SHA when the event file is missing', () => { + process.env.GITHUB_ACTIONS = 'true'; + process.env.GITHUB_EVENT_PATH = '/nonexistent/path/event.json'; + process.env.GITHUB_SHA = 'fallback-sha'; + + resetGitHubEventCache(); + + assert.strictEqual(getCommit(), 'fallback-sha'); + }); + + it('falls back to GITHUB_SHA when the event is not a pull request', () => { + let eventPath = createTempEventFile({ ref: 'refs/heads/main' }); + + process.env.GITHUB_ACTIONS = 'true'; + process.env.GITHUB_EVENT_PATH = eventPath; + process.env.GITHUB_SHA = 'push-commit-sha'; + + resetGitHubEventCache(); + + assert.strictEqual(getCommit(), 'push-commit-sha'); + }); + it('reads generic COMMIT_SHA', () => { process.env.COMMIT_SHA = 'generic-sha'; @@ -338,6 +401,22 @@ describe('utils/ci-env', () => { assert.strictEqual(getPullRequestHeadSha(), 'jenkins-pr-sha'); }); + + it('reads the PR head SHA from a GitHub Actions event file', () => { + let eventPath = createTempEventFile({ + pull_request: { + head: { sha: 'pr-head-sha-from-event' }, + base: { sha: 'base-sha' }, + }, + }); + + process.env.GITHUB_ACTIONS = 'true'; + process.env.GITHUB_EVENT_PATH = eventPath; + + resetGitHubEventCache(); + + assert.strictEqual(getPullRequestHeadSha(), 'pr-head-sha-from-event'); + }); }); describe('getPullRequestBaseSha', () => { @@ -356,6 +435,54 @@ describe('utils/ci-env', () => { assert.strictEqual(getPullRequestBaseSha(), 'gitlab-base'); }); + + it('reads the PR base SHA from a GitHub Actions event file', () => { + let eventPath = createTempEventFile({ + pull_request: { + head: { sha: 'head-sha' }, + base: { sha: 'base-sha-from-event' }, + }, + }); + + process.env.GITHUB_ACTIONS = 'true'; + process.env.GITHUB_EVENT_PATH = eventPath; + + resetGitHubEventCache(); + + assert.strictEqual(getPullRequestBaseSha(), 'base-sha-from-event'); + }); + }); + + describe('getGitHubEvent', () => { + it('returns an empty object when no event path is set', () => { + assert.deepStrictEqual(getGitHubEvent(), {}); + }); + + it('parses and caches the event file', () => { + let eventPath = createTempEventFile({ action: 'opened', number: 42 }); + + process.env.GITHUB_EVENT_PATH = eventPath; + resetGitHubEventCache(); + + let event = getGitHubEvent(); + assert.deepStrictEqual(event, { action: 'opened', number: 42 }); + + unlinkSync(eventPath); + tempFilesToCleanup = tempFilesToCleanup.filter( + file => file !== eventPath + ); + + assert.deepStrictEqual(getGitHubEvent(), event); + }); + + it('returns an empty object for invalid JSON', () => { + let eventPath = createTempEventFile('not valid json {{{'); + + process.env.GITHUB_EVENT_PATH = eventPath; + resetGitHubEventCache(); + + assert.deepStrictEqual(getGitHubEvent(), {}); + }); }); describe('getPullRequestHeadRef', () => { From 071ad080bf765842f42ff1bd9369e5dc2d47d48a Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Sun, 9 Aug 2026 16:25:02 -0500 Subject: [PATCH 2/2] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20tighten=20dead-code=20?= =?UTF-8?q?cleanup=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the public Ember browser mapping export, remove an unused events-router export, and drop the internal TDD dependency-wiring test after the full simplification audit. --- clients/ember/src/testem-config.js | 2 + .../ember/tests/unit/testem-config.test.js | 15 ++++++- src/server/routers/events.js | 2 +- tests/tdd/runtime-context.test.js | 40 +------------------ 4 files changed, 18 insertions(+), 41 deletions(-) diff --git a/clients/ember/src/testem-config.js b/clients/ember/src/testem-config.js index 206ac216..21e74810 100644 --- a/clients/ember/src/testem-config.js +++ b/clients/ember/src/testem-config.js @@ -25,6 +25,8 @@ let browserMappings = { webkit: 'VizzlyWebKit', }; +export { browserMappings }; + /** * Remap browser names to Vizzly launcher names * @param {string[]} browsers - Array of browser names diff --git a/clients/ember/tests/unit/testem-config.test.js b/clients/ember/tests/unit/testem-config.test.js index 06a7feec..5e353bee 100644 --- a/clients/ember/tests/unit/testem-config.test.js +++ b/clients/ember/tests/unit/testem-config.test.js @@ -2,7 +2,7 @@ import assert from 'node:assert'; import { existsSync, readFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { afterEach, describe, it } from 'node:test'; -import { configure } from '../../src/testem-config.js'; +import { browserMappings, configure } from '../../src/testem-config.js'; describe('testem-config', () => { afterEach(() => { @@ -13,6 +13,19 @@ describe('testem-config', () => { } }); + it('preserves the public browser mappings', () => { + assert.deepStrictEqual(browserMappings, { + Chrome: 'VizzlyChrome', + chrome: 'VizzlyChrome', + Firefox: 'VizzlyFirefox', + firefox: 'VizzlyFirefox', + Safari: 'VizzlyWebKit', + safari: 'VizzlyWebKit', + WebKit: 'VizzlyWebKit', + webkit: 'VizzlyWebKit', + }); + }); + describe('configure()', () => { it('returns config with Vizzly launchers when given empty object', () => { let result = configure({}); diff --git a/src/server/routers/events.js b/src/server/routers/events.js index e02f1be2..d6ecf4a0 100644 --- a/src/server/routers/events.js +++ b/src/server/routers/events.js @@ -21,7 +21,7 @@ let defaultTimers = { /** * Read and parse JSON from disk, returning null on missing or invalid files. */ -export function readJsonFile(path) { +function readJsonFile(path) { if (!existsSync(path)) { return null; } diff --git a/tests/tdd/runtime-context.test.js b/tests/tdd/runtime-context.test.js index 789014ec..249dd544 100644 --- a/tests/tdd/runtime-context.test.js +++ b/tests/tdd/runtime-context.test.js @@ -1,46 +1,8 @@ import assert from 'node:assert'; import { describe, it } from 'node:test'; -import { - buildTddDependencyOps, - resolveTddWorkingDirectory, -} from '../../src/tdd/runtime-context.js'; +import { resolveTddWorkingDirectory } from '../../src/tdd/runtime-context.js'; describe('tdd/runtime-context', () => { - it('merges grouped dependencies with defaults', () => { - let defaults = { - output: { info: () => {} }, - colors: { green: s => s }, - validatePathSecurity: path => path, - initializeDirectories: () => ({}), - calculateHotspotCoverage: () => ({}), - fs: { existsSync: () => false, readFileSync: () => Buffer.from('x') }, - api: { createApiClient: () => ({}) }, - metadata: { loadBaselineMetadata: () => null }, - baseline: { baselineExists: () => false }, - comparison: { compareImages: async () => ({}) }, - signature: { generateComparisonId: sig => sig }, - results: { buildResults: () => ({ total: 0 }) }, - }; - let customCreateApiClient = () => ({ custom: true }); - let customExistsSync = () => true; - - let { runtimeDeps, apiOps } = buildTddDependencyOps( - { - fs: { existsSync: customExistsSync }, - api: { createApiClient: customCreateApiClient }, - }, - defaults - ); - - assert.strictEqual(runtimeDeps.existsSync, customExistsSync); - assert.strictEqual(runtimeDeps.createApiClient, customCreateApiClient); - assert.strictEqual( - runtimeDeps.loadBaselineMetadata, - defaults.metadata.loadBaselineMetadata - ); - assert.strictEqual(apiOps.createApiClient, customCreateApiClient); - }); - it('returns validated working directory when path is valid', () => { let output = { error: () => {} }; let validated = resolveTddWorkingDirectory(