From 8f920eca040b70aa1ae7ebd1741282469eca6494 Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Thu, 29 Jan 2026 23:21:23 -0600 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20Add=20git=20detection=20API=20t?= =?UTF-8?q?o=20plugin=20services?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `services.git.detect()` to the plugin API, providing plugins with correct git information detection that handles CI environments properly (GitHub Actions PR merge commits, GitLab, CircleCI, etc.). This replaces the fragile dynamic import approach in storybook and static-site plugins with a clean API contract. Plugins now use the CLI's battle-tested git detection instead of duplicating the logic. Changes: - Add `services.git.detect({ buildPrefix })` to plugin-api.js - Update storybook plugin to use new API with graceful fallback - Update static-site plugin to use new API with graceful fallback - Bump peer dependency to >=0.25.0 (with fallback for older CLIs) --- clients/static-site/package.json | 2 +- clients/static-site/src/index.js | 57 ++++++++++++-------------------- clients/storybook/package.json | 2 +- clients/storybook/src/index.js | 55 +++++++++++------------------- src/plugin-api.js | 38 +++++++++++++++++++++ 5 files changed, 82 insertions(+), 72 deletions(-) diff --git a/clients/static-site/package.json b/clients/static-site/package.json index 185388d..052a6ee 100644 --- a/clients/static-site/package.json +++ b/clients/static-site/package.json @@ -62,7 +62,7 @@ "registry": "https://registry.npmjs.org/" }, "peerDependencies": { - "@vizzly-testing/cli": ">=0.9.0" + "@vizzly-testing/cli": ">=0.25.0" }, "dependencies": { "cosmiconfig": "^9.0.0", diff --git a/clients/static-site/src/index.js b/clients/static-site/src/index.js index 3f622bd..7cf72e1 100644 --- a/clients/static-site/src/index.js +++ b/clients/static-site/src/index.js @@ -166,43 +166,30 @@ export async function run(buildPath, options = {}, context = {}) { } }); - // Detect git info - use dynamic import to access internal utils - let gitUtils; - try { - // Try to import from the installed CLI package - let cliPath = await import.meta.resolve?.('@vizzly-testing/cli'); - if (cliPath) { - gitUtils = await import( - '@vizzly-testing/cli/dist/utils/git.js' - ).catch(() => null); - } - } catch { - // Fallback: try relative path if in monorepo - try { - gitUtils = await import('../../../src/utils/git.js').catch( - () => null - ); - } catch { - gitUtils = null; - } + // Detect git info using CLI's plugin API (preferred) or fallback to env vars + let branch, commit, message, buildName, pullRequestNumber; + + if (services.git?.detect) { + // Use CLI's git detection (correct handling of CI environments) + let gitInfo = await services.git.detect({ + buildPrefix: 'Static Site', + }); + branch = gitInfo.branch; + commit = gitInfo.commit; + message = gitInfo.message; + buildName = gitInfo.buildName; + pullRequestNumber = gitInfo.prNumber; + } else { + // Fallback for older CLI versions - use environment variables + branch = process.env.VIZZLY_BRANCH || 'main'; + commit = process.env.VIZZLY_COMMIT_SHA || undefined; + message = process.env.VIZZLY_COMMIT_MESSAGE || undefined; + buildName = `Static Site ${new Date().toISOString()}`; + pullRequestNumber = process.env.VIZZLY_PR_NUMBER + ? parseInt(process.env.VIZZLY_PR_NUMBER, 10) + : undefined; } - let branch = gitUtils - ? await gitUtils.detectBranch() - : process.env.VIZZLY_BRANCH || 'main'; - let commit = gitUtils - ? await gitUtils.detectCommit() - : process.env.VIZZLY_COMMIT_SHA || undefined; - let message = gitUtils - ? await gitUtils.detectCommitMessage() - : process.env.VIZZLY_COMMIT_MESSAGE || undefined; - let buildName = gitUtils - ? await gitUtils.generateBuildNameWithGit('Static Site') - : `Static Site ${new Date().toISOString()}`; - let pullRequestNumber = gitUtils - ? gitUtils.detectPullRequestNumber() - : process.env.VIZZLY_PR_NUMBER || undefined; - // Build options for API let runOptions = { port: vizzlyConfig?.server?.port || 47392, diff --git a/clients/storybook/package.json b/clients/storybook/package.json index efdba78..729a813 100644 --- a/clients/storybook/package.json +++ b/clients/storybook/package.json @@ -58,7 +58,7 @@ "registry": "https://registry.npmjs.org/" }, "peerDependencies": { - "@vizzly-testing/cli": ">=0.9.0" + "@vizzly-testing/cli": ">=0.25.0" }, "dependencies": { "playwright-core": "^1.50.0", diff --git a/clients/storybook/src/index.js b/clients/storybook/src/index.js index f66640b..7acc7cd 100644 --- a/clients/storybook/src/index.js +++ b/clients/storybook/src/index.js @@ -112,43 +112,28 @@ export async function run(storybookPath, options = {}, context = {}) { } }); - // Detect git info - use dynamic import to access internal utils - let gitUtils; - try { - // Try to import from the installed CLI package - let cliPath = await import.meta.resolve?.('@vizzly-testing/cli'); - if (cliPath) { - gitUtils = await import( - '@vizzly-testing/cli/dist/utils/git.js' - ).catch(() => null); - } - } catch { - // Fallback: try relative path if in monorepo - try { - gitUtils = await import('../../../src/utils/git.js').catch( - () => null - ); - } catch { - gitUtils = null; - } + // Detect git info using CLI's plugin API (preferred) or fallback to env vars + let branch, commit, message, buildName, pullRequestNumber; + + if (services.git?.detect) { + // Use CLI's git detection (correct handling of CI environments) + let gitInfo = await services.git.detect({ buildPrefix: 'Storybook' }); + branch = gitInfo.branch; + commit = gitInfo.commit; + message = gitInfo.message; + buildName = gitInfo.buildName; + pullRequestNumber = gitInfo.prNumber; + } else { + // Fallback for older CLI versions - use environment variables + branch = process.env.VIZZLY_BRANCH || 'main'; + commit = process.env.VIZZLY_COMMIT_SHA || undefined; + message = process.env.VIZZLY_COMMIT_MESSAGE || undefined; + buildName = `Storybook ${new Date().toISOString()}`; + pullRequestNumber = process.env.VIZZLY_PR_NUMBER + ? parseInt(process.env.VIZZLY_PR_NUMBER, 10) + : undefined; } - let branch = gitUtils - ? await gitUtils.detectBranch() - : process.env.VIZZLY_BRANCH || 'main'; - let commit = gitUtils - ? await gitUtils.detectCommit() - : process.env.VIZZLY_COMMIT_SHA || undefined; - let message = gitUtils - ? await gitUtils.detectCommitMessage() - : process.env.VIZZLY_COMMIT_MESSAGE || undefined; - let buildName = gitUtils - ? await gitUtils.generateBuildNameWithGit('Storybook') - : `Storybook ${new Date().toISOString()}`; - let pullRequestNumber = gitUtils - ? gitUtils.detectPullRequestNumber() - : process.env.VIZZLY_PR_NUMBER || undefined; - // Build options for API let runOptions = { port: vizzlyConfig?.server?.port || 47392, diff --git a/src/plugin-api.js b/src/plugin-api.js index 696ea65..8e46243 100644 --- a/src/plugin-api.js +++ b/src/plugin-api.js @@ -9,10 +9,19 @@ * exposed to plugins to prevent coupling to implementation details. */ +import { + detectBranch, + detectCommit, + detectCommitMessage, + detectPullRequestNumber, + generateBuildNameWithGit, +} from './utils/git.js'; + /** * Creates a stable plugin services object from the internal services * * Only exposes: + * - git: Git information detection (branch, commit, PR number, etc.) * - testRunner: Build lifecycle management (createBuild, finalizeBuild, events) * - serverManager: Screenshot server control (start, stop) * @@ -23,6 +32,35 @@ export function createPluginServices(services) { let { testRunner, serverManager } = services; return Object.freeze({ + // Git detection utilities - provides correct git info from CI environments + git: Object.freeze({ + /** + * Detect git information for build creation + * Handles CI environment variables correctly (GitHub Actions, GitLab, etc.) + * + * @param {Object} [options] - Detection options + * @param {string} [options.buildPrefix] - Prefix for generated build name + * @returns {Promise} Git info: { branch, commit, message, prNumber, buildName } + */ + async detect(options = {}) { + let [branch, commit, message] = await Promise.all([ + detectBranch(), + detectCommit(), + detectCommitMessage(), + ]); + let prNumber = detectPullRequestNumber(); + let buildName = await generateBuildNameWithGit(options.buildPrefix); + + return { + branch, + commit, + message, + prNumber, + buildName, + }; + }, + }), + testRunner: Object.freeze({ // EventEmitter methods for build lifecycle events once: testRunner.once.bind(testRunner), From bd8371ca531be42dd6c5dda3dbdc413064921f7b Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Thu, 29 Jan 2026 23:29:21 -0600 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=A7=AA=20Add=20plugin=20API=20tests?= =?UTF-8?q?=20and=20fix=20CI=20peer=20dep=20issue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add comprehensive tests for services.git.detect() API - Fix peer dep to >=0.24.0 (0.25.0 doesn't exist on npm yet) - Add warning when falling back to env vars on older CLI versions --- clients/static-site/package.json | 2 +- clients/static-site/src/index.js | 3 + clients/storybook/package.json | 2 +- clients/storybook/src/index.js | 3 + tests/unit/plugin-api.test.js | 169 +++++++++++++++++++++++++++++++ 5 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 tests/unit/plugin-api.test.js diff --git a/clients/static-site/package.json b/clients/static-site/package.json index 052a6ee..792ad5b 100644 --- a/clients/static-site/package.json +++ b/clients/static-site/package.json @@ -62,7 +62,7 @@ "registry": "https://registry.npmjs.org/" }, "peerDependencies": { - "@vizzly-testing/cli": ">=0.25.0" + "@vizzly-testing/cli": ">=0.24.0" }, "dependencies": { "cosmiconfig": "^9.0.0", diff --git a/clients/static-site/src/index.js b/clients/static-site/src/index.js index 7cf72e1..99eccc9 100644 --- a/clients/static-site/src/index.js +++ b/clients/static-site/src/index.js @@ -181,6 +181,9 @@ export async function run(buildPath, options = {}, context = {}) { pullRequestNumber = gitInfo.prNumber; } else { // Fallback for older CLI versions - use environment variables + logger.warn( + '⚠️ Upgrade to @vizzly-testing/cli@>=0.25.0 for improved git detection' + ); branch = process.env.VIZZLY_BRANCH || 'main'; commit = process.env.VIZZLY_COMMIT_SHA || undefined; message = process.env.VIZZLY_COMMIT_MESSAGE || undefined; diff --git a/clients/storybook/package.json b/clients/storybook/package.json index 729a813..7a18a74 100644 --- a/clients/storybook/package.json +++ b/clients/storybook/package.json @@ -58,7 +58,7 @@ "registry": "https://registry.npmjs.org/" }, "peerDependencies": { - "@vizzly-testing/cli": ">=0.25.0" + "@vizzly-testing/cli": ">=0.24.0" }, "dependencies": { "playwright-core": "^1.50.0", diff --git a/clients/storybook/src/index.js b/clients/storybook/src/index.js index 7acc7cd..27f8edd 100644 --- a/clients/storybook/src/index.js +++ b/clients/storybook/src/index.js @@ -125,6 +125,9 @@ export async function run(storybookPath, options = {}, context = {}) { pullRequestNumber = gitInfo.prNumber; } else { // Fallback for older CLI versions - use environment variables + logger.warn( + '⚠️ Upgrade to @vizzly-testing/cli@>=0.25.0 for improved git detection' + ); branch = process.env.VIZZLY_BRANCH || 'main'; commit = process.env.VIZZLY_COMMIT_SHA || undefined; message = process.env.VIZZLY_COMMIT_MESSAGE || undefined; diff --git a/tests/unit/plugin-api.test.js b/tests/unit/plugin-api.test.js new file mode 100644 index 0000000..e2f4a6b --- /dev/null +++ b/tests/unit/plugin-api.test.js @@ -0,0 +1,169 @@ +import assert from 'node:assert'; +import { afterEach, beforeEach, describe, it } from 'node:test'; +import { createPluginServices } from '../../src/plugin-api.js'; +import { resetGitHubEventCache } from '../../src/utils/ci-env.js'; + +describe('Plugin API', () => { + let originalEnv; + + // Minimal mock services required by createPluginServices + let mockServices = { + testRunner: { + once: () => {}, + on: () => {}, + off: () => {}, + createBuild: () => {}, + finalizeBuild: () => {}, + }, + serverManager: { + start: () => {}, + stop: () => {}, + }, + }; + + beforeEach(() => { + originalEnv = { ...process.env }; + // Clear CI-related environment variables + let ciVars = [ + 'VIZZLY_BRANCH', + 'VIZZLY_COMMIT_SHA', + 'VIZZLY_COMMIT_MESSAGE', + 'VIZZLY_PR_NUMBER', + 'GITHUB_ACTIONS', + 'GITHUB_HEAD_REF', + 'GITHUB_REF_NAME', + 'GITHUB_SHA', + 'GITHUB_EVENT_NAME', + 'GITHUB_EVENT_PATH', + 'GITHUB_REF', + ]; + for (let key of ciVars) { + delete process.env[key]; + } + resetGitHubEventCache(); + }); + + afterEach(() => { + process.env = originalEnv; + resetGitHubEventCache(); + }); + + describe('createPluginServices', () => { + it('returns frozen object with expected shape', () => { + let services = createPluginServices(mockServices); + + assert.ok(Object.isFrozen(services), 'services should be frozen'); + assert.ok(services.git, 'should have git property'); + assert.ok(services.testRunner, 'should have testRunner property'); + assert.ok(services.serverManager, 'should have serverManager property'); + }); + + it('exposes git.detect as a function', () => { + let services = createPluginServices(mockServices); + + assert.strictEqual(typeof services.git.detect, 'function'); + }); + + it('exposes testRunner methods', () => { + let services = createPluginServices(mockServices); + + assert.strictEqual(typeof services.testRunner.once, 'function'); + assert.strictEqual(typeof services.testRunner.on, 'function'); + assert.strictEqual(typeof services.testRunner.off, 'function'); + assert.strictEqual(typeof services.testRunner.createBuild, 'function'); + assert.strictEqual(typeof services.testRunner.finalizeBuild, 'function'); + }); + + it('exposes serverManager methods', () => { + let services = createPluginServices(mockServices); + + assert.strictEqual(typeof services.serverManager.start, 'function'); + assert.strictEqual(typeof services.serverManager.stop, 'function'); + }); + }); + + describe('services.git.detect', () => { + it('returns object with expected properties', async () => { + let services = createPluginServices(mockServices); + let result = await services.git.detect(); + + assert.ok('branch' in result, 'should have branch property'); + assert.ok('commit' in result, 'should have commit property'); + assert.ok('message' in result, 'should have message property'); + assert.ok('prNumber' in result, 'should have prNumber property'); + assert.ok('buildName' in result, 'should have buildName property'); + }); + + it('detects branch from local git repo', async () => { + let services = createPluginServices(mockServices); + let result = await services.git.detect(); + + // We're in a git repo, so branch should be detected + assert.ok(result.branch, 'branch should be detected'); + assert.strictEqual(typeof result.branch, 'string'); + }); + + it('detects commit from local git repo', async () => { + let services = createPluginServices(mockServices); + let result = await services.git.detect(); + + // We're in a git repo, so commit should be detected + assert.ok(result.commit, 'commit should be detected'); + assert.strictEqual( + result.commit.length, + 40, + 'commit should be 40 char SHA' + ); + }); + + it('uses buildPrefix in buildName when provided', async () => { + let services = createPluginServices(mockServices); + let result = await services.git.detect({ buildPrefix: 'Storybook' }); + + assert.ok(result.buildName, 'buildName should be present'); + assert.ok( + result.buildName.startsWith('Storybook'), + `buildName should start with prefix, got: ${result.buildName}` + ); + }); + + it('respects VIZZLY_BRANCH environment variable', async () => { + process.env.VIZZLY_BRANCH = 'custom-branch'; + + let services = createPluginServices(mockServices); + let result = await services.git.detect(); + + assert.strictEqual(result.branch, 'custom-branch'); + }); + + it('respects VIZZLY_COMMIT_SHA environment variable', async () => { + process.env.VIZZLY_COMMIT_SHA = 'abc123def456'; + + let services = createPluginServices(mockServices); + let result = await services.git.detect(); + + assert.strictEqual(result.commit, 'abc123def456'); + }); + + it('detects GitHub Actions PR context', async () => { + process.env.GITHUB_ACTIONS = 'true'; + process.env.GITHUB_HEAD_REF = 'feature/my-branch'; + process.env.GITHUB_EVENT_NAME = 'pull_request'; + process.env.GITHUB_REF = 'refs/pull/42/merge'; + + let services = createPluginServices(mockServices); + let result = await services.git.detect(); + + assert.strictEqual(result.branch, 'feature/my-branch'); + assert.strictEqual(result.prNumber, 42); + }); + + it('returns null for prNumber when not in PR context', async () => { + let services = createPluginServices(mockServices); + let result = await services.git.detect(); + + // Not in a PR context, so prNumber should be null + assert.strictEqual(result.prNumber, null); + }); + }); +}); From ce8d33bb6624631d4090ace52df830ad6a77185f Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Thu, 29 Jan 2026 23:39:39 -0600 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=93=9D=20Document=20services.git=20AP?= =?UTF-8?q?I=20in=20plugin=20example?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add git.detect() demo to check-services command - Document Services API in plugin README --- examples/custom-plugin/README.md | 24 ++++++++++++++++++++++-- examples/custom-plugin/plugin.js | 13 ++++++++++++- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/examples/custom-plugin/README.md b/examples/custom-plugin/README.md index c5fc184..d8d2fe6 100644 --- a/examples/custom-plugin/README.md +++ b/examples/custom-plugin/README.md @@ -71,8 +71,28 @@ The `register` function receives: 1. **`program`** - Commander.js program instance for adding commands 2. **`context`** - Object containing: - `config` - Merged Vizzly configuration - - `logger` - Component logger for consistent output - - `services` - Service container with API client, uploader, etc. + - `output` - Output utilities for consistent CLI output + - `services` - Service container (see below) + +### Services API + +The `services` object provides stable APIs for plugins: + +```javascript +let { git, testRunner, serverManager } = services; + +// Git detection (v0.25.0+) - handles CI environments correctly +let gitInfo = await git.detect({ buildPrefix: 'MyPlugin' }); +// Returns: { branch, commit, message, prNumber, buildName } + +// Build lifecycle +let buildId = await testRunner.createBuild(options); +await testRunner.finalizeBuild(buildId, wait, success, executionTime); + +// Server control +await serverManager.start(buildId, tddMode, setBaseline); +await serverManager.stop(); +``` ## Creating Your Own Plugin diff --git a/examples/custom-plugin/plugin.js b/examples/custom-plugin/plugin.js index c004989..c0d9bd4 100644 --- a/examples/custom-plugin/plugin.js +++ b/examples/custom-plugin/plugin.js @@ -53,7 +53,18 @@ export default { output.info('Checking Vizzly services...'); // Access services from the stable API - let { testRunner, serverManager } = services; + let { git, testRunner, serverManager } = services; + + // Verify git detection is available (v0.25.0+) + if (git?.detect) { + output.success('git.detect is available'); + let gitInfo = await git.detect({ buildPrefix: 'Example' }); + output.info(` Branch: ${gitInfo.branch}`); + output.info(` Commit: ${gitInfo.commit?.slice(0, 7) || 'unknown'}`); + output.info(` PR: ${gitInfo.prNumber || 'none'}`); + } else { + output.warn('git.detect not available (requires CLI v0.25.0+)'); + } // Verify testRunner is available if (typeof testRunner.createBuild === 'function') {