From 1293773b00b9b1d6a17b546b1675695a7ffa7bab Mon Sep 17 00:00:00 2001 From: Steve Worley Date: Mon, 15 Jun 2026 15:02:02 +1000 Subject: [PATCH] feat(vrt): add --config flag to qc vrt Allow `qc vrt --config ` to read a specific VRT config file instead of always using ~/.quant/vrt-config.json. Enables generating the config from an external source (e.g. a Pulumi stack output) without clobbering the user's personal config. Adds loadVRTConfigFrom(path) and a testable resolveVRTConfig() selector; an explicit --config that is missing/invalid errors loudly (no silent fallback). README updated; unit tests added (no Chromium/network). Co-Authored-By: Claude Opus 4.8 --- README.md | 6 ++ __tests__/utils/vrt-config.test.ts | 46 ++++++++++++++ __tests__/utils/vrt-resolve-config.test.ts | 73 ++++++++++++++++++++++ src/commands/vrt.ts | 21 ++++++- src/utils/config.ts | 40 +++++++++++- 5 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 __tests__/utils/vrt-config.test.ts create mode 100644 __tests__/utils/vrt-resolve-config.test.ts diff --git a/README.md b/README.md index 4516f94..c2fbb94 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,7 @@ Run automated visual regression testing to compare Quant projects against remote - `qc vrt --output-dir=./screenshots` - Set screenshot output directory - `qc vrt --quant-auth=user:pass` - Basic auth for Quant URLs - `qc vrt --remote-auth=user:pass` - Basic auth for remote URLs +- `qc vrt --config=./path/to/vrt-config.json` - Use a specific VRT config file instead of the default `~/.quant/vrt-config.json` **Configuration:** Create `~/.quant/vrt-config.json` with project mappings: @@ -247,6 +248,11 @@ qc vrt --quant-auth=user:pass --remote-auth=user:pass # Run with custom limits qc vrt --max-pages=50 --max-depth=5 --output-dir=./my-screenshots + +# Run against a config generated elsewhere (e.g. from Pulumi) without +# clobbering your personal ~/.quant/vrt-config.json +pulumi stack output vrtConfig --json > ./vrt-config.json +qc vrt --config=./vrt-config.json ``` **Output:** diff --git a/__tests__/utils/vrt-config.test.ts b/__tests__/utils/vrt-config.test.ts new file mode 100644 index 0000000..d88efd9 --- /dev/null +++ b/__tests__/utils/vrt-config.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; +import { promises as fs } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { loadVRTConfigFrom, VRTConfig } from '../../src/utils/config.js'; + +describe('loadVRTConfigFrom', () => { + let tempDir: string; + + beforeAll(async () => { + tempDir = await fs.mkdtemp(join(tmpdir(), 'qc-vrt-config-')); + }); + + afterAll(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('returns the parsed config for a valid file', async () => { + const expected: VRTConfig = { + projects: { 'my-project': 'https://example.com' }, + threshold: 0.05, + maxPages: 20 + }; + const path = join(tempDir, 'valid.json'); + await fs.writeFile(path, JSON.stringify(expected), 'utf-8'); + + const config = await loadVRTConfigFrom(path); + + expect(config).toEqual(expected); + }); + + it('returns null when the file does not exist', async () => { + const config = await loadVRTConfigFrom(join(tempDir, 'does-not-exist.json')); + + expect(config).toBeNull(); + }); + + it('returns null when the file contains invalid JSON', async () => { + const path = join(tempDir, 'invalid.json'); + await fs.writeFile(path, '{ not valid json', 'utf-8'); + + const config = await loadVRTConfigFrom(path); + + expect(config).toBeNull(); + }); +}); diff --git a/__tests__/utils/vrt-resolve-config.test.ts b/__tests__/utils/vrt-resolve-config.test.ts new file mode 100644 index 0000000..801f5a2 --- /dev/null +++ b/__tests__/utils/vrt-resolve-config.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, jest } from '@jest/globals'; +import { resolve } from 'path'; +import { + resolveVRTConfig, + loadVRTConfig, + loadVRTConfigFrom, + VRTConfig +} from '../../src/utils/config.js'; + +const sampleConfig: VRTConfig = { + projects: { 'my-project': 'https://example.com' } +}; + +const mockDefault = () => jest.fn(); +const mockFrom = () => jest.fn(); + +describe('resolveVRTConfig', () => { + it('loads from the default loader when no --config path is given', async () => { + const loadDefault = mockDefault().mockResolvedValue(sampleConfig); + const loadFrom = mockFrom().mockResolvedValue(null); + + const result = await resolveVRTConfig(undefined, { loadDefault, loadFrom }); + + expect(loadDefault).toHaveBeenCalledTimes(1); + expect(loadFrom).not.toHaveBeenCalled(); + expect(result.config).toEqual(sampleConfig); + expect(result.explicitPath).toBeUndefined(); + }); + + it('loads from the explicit path, resolved against cwd', async () => { + const loadDefault = mockDefault().mockResolvedValue(null); + const loadFrom = mockFrom().mockResolvedValue(sampleConfig); + + const result = await resolveVRTConfig( + './vrt-config.json', + { loadDefault, loadFrom }, + '/home/user/work' + ); + + const expectedPath = resolve('/home/user/work', './vrt-config.json'); + expect(loadDefault).not.toHaveBeenCalled(); + expect(loadFrom).toHaveBeenCalledWith(expectedPath); + expect(result.config).toEqual(sampleConfig); + expect(result.explicitPath).toBe(expectedPath); + }); + + it('leaves an absolute --config path unchanged', async () => { + const loadFrom = mockFrom().mockResolvedValue(sampleConfig); + + const result = await resolveVRTConfig( + '/etc/quant/vrt.json', + { loadDefault: mockDefault().mockResolvedValue(null), loadFrom }, + '/home/user/work' + ); + + expect(loadFrom).toHaveBeenCalledWith('/etc/quant/vrt.json'); + expect(result.explicitPath).toBe('/etc/quant/vrt.json'); + }); + + it('surfaces a null config (with the resolved path) when the explicit file is unreadable', async () => { + const loadFrom = mockFrom().mockResolvedValue(null); + + const result = await resolveVRTConfig( + 'missing.json', + { loadDefault: mockDefault().mockResolvedValue(sampleConfig), loadFrom }, + '/tmp' + ); + + // Must NOT fall back to the default config. + expect(result.config).toBeNull(); + expect(result.explicitPath).toBe(resolve('/tmp', 'missing.json')); + }); +}); diff --git a/src/commands/vrt.ts b/src/commands/vrt.ts index 3d13b40..f09c243 100644 --- a/src/commands/vrt.ts +++ b/src/commands/vrt.ts @@ -8,7 +8,7 @@ import { join } from 'path'; import { createSpinner } from '../utils/spinner.js'; import { ApiClient } from '../utils/api.js'; import { Logger } from '../utils/logger.js'; -import { loadVRTConfig, VRTConfig } from '../utils/config.js'; +import { resolveVRTConfig, VRTConfig } from '../utils/config.js'; const logger = new Logger('VRT'); @@ -21,6 +21,7 @@ interface VRTOptions { quantAuth?: string; remoteAuth?: string; outputDir?: string; + config?: string; } interface VRTResult { @@ -52,6 +53,7 @@ export function vrtCommand(program: Command) { .option('--quant-auth ', 'basic auth for Quant URLs (user:pass)') .option('--remote-auth ', 'basic auth for remote URLs (user:pass)') .option('--output-dir ', 'output directory for screenshots') + .option('--config ', 'path to a VRT config file (default: ~/.quant/vrt-config.json)') .action(async (options: VRTOptions) => { await handleVRT(options); }); @@ -61,9 +63,22 @@ export function vrtCommand(program: Command) { async function handleVRT(options: VRTOptions) { try { - // Load VRT configuration - const config = await loadVRTConfig(); + // Load VRT configuration from the requested source + const { config, explicitPath } = await resolveVRTConfig(options.config); + + // An explicit --config path that is missing/invalid is a hard error - + // never silently fall back to the default config. + if (explicitPath && !config) { + logger.error(`Could not read VRT config file: ${chalk.cyan(explicitPath)}`); + logger.info('Ensure the path exists and contains valid JSON.'); + process.exit(1); + } + if (!config || Object.keys(config.projects).length === 0) { + if (explicitPath) { + logger.error(`No VRT projects defined in ${chalk.cyan(explicitPath)}`); + process.exit(1); + } logger.error('No VRT projects configured.'); logger.info(`Configure projects by creating ${chalk.cyan('~/.quant/vrt-config.json')}`); logger.info('Example format:'); diff --git a/src/utils/config.ts b/src/utils/config.ts index 4833699..d72529a 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -1,6 +1,6 @@ import { promises as fs } from 'fs'; import { homedir } from 'os'; -import { join } from 'path'; +import { join, resolve } from 'path'; import { AuthConfig, MultiPlatformConfig, PlatformInfo } from '../types/auth.js'; const CONFIG_DIR = join(homedir(), '.quant'); @@ -233,15 +233,49 @@ export async function loadAuthConfigCompat(): Promise { } // VRT configuration management -export async function loadVRTConfig(): Promise { + +// Load a VRT config from an explicit file path. Returns null if the file +// cannot be read or parsed (same null-on-failure semantics as loadVRTConfig). +export async function loadVRTConfigFrom(filePath: string): Promise { try { - const data = await fs.readFile(VRT_CONFIG_FILE, 'utf-8'); + const data = await fs.readFile(filePath, 'utf-8'); return JSON.parse(data); } catch { return null; } } +export async function loadVRTConfig(): Promise { + return loadVRTConfigFrom(VRT_CONFIG_FILE); +} + +export interface ResolvedVRTConfig { + config: VRTConfig | null; + /** Absolute path to an explicit --config file, when one was provided. */ + explicitPath?: string; +} + +/** + * Selects the VRT config source. When an explicit --config path is given it is + * resolved against the cwd and loaded from there; otherwise the default + * ~/.quant/vrt-config.json is used. The loaders are injectable for testing. + */ +export async function resolveVRTConfig( + configPath: string | undefined, + loaders: { + loadDefault: typeof loadVRTConfig; + loadFrom: typeof loadVRTConfigFrom; + } = { loadDefault: loadVRTConfig, loadFrom: loadVRTConfigFrom }, + cwd: string = process.cwd() +): Promise { + if (configPath) { + const explicitPath = resolve(cwd, configPath); + const config = await loaders.loadFrom(explicitPath); + return { config, explicitPath }; + } + return { config: await loaders.loadDefault() }; +} + export async function saveVRTConfig(config: VRTConfig): Promise { await ensureConfigDir(); await fs.writeFile(VRT_CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');