Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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:**
Expand Down
46 changes: 46 additions & 0 deletions __tests__/utils/vrt-config.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
73 changes: 73 additions & 0 deletions __tests__/utils/vrt-resolve-config.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof loadVRTConfig>();
const mockFrom = () => jest.fn<typeof loadVRTConfigFrom>();

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'));
});
});
21 changes: 18 additions & 3 deletions src/commands/vrt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -21,6 +21,7 @@ interface VRTOptions {
quantAuth?: string;
remoteAuth?: string;
outputDir?: string;
config?: string;
}

interface VRTResult {
Expand Down Expand Up @@ -52,6 +53,7 @@ export function vrtCommand(program: Command) {
.option('--quant-auth <credentials>', 'basic auth for Quant URLs (user:pass)')
.option('--remote-auth <credentials>', 'basic auth for remote URLs (user:pass)')
.option('--output-dir <dir>', 'output directory for screenshots')
.option('--config <path>', 'path to a VRT config file (default: ~/.quant/vrt-config.json)')
.action(async (options: VRTOptions) => {
await handleVRT(options);
});
Expand All @@ -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:');
Expand Down
40 changes: 37 additions & 3 deletions src/utils/config.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -233,15 +233,49 @@ export async function loadAuthConfigCompat(): Promise<AuthConfig | null> {
}

// VRT configuration management
export async function loadVRTConfig(): Promise<VRTConfig | null> {

// 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<VRTConfig | null> {
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<VRTConfig | null> {
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<ResolvedVRTConfig> {
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<void> {
await ensureConfigDir();
await fs.writeFile(VRT_CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
Expand Down
Loading