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
2 changes: 1 addition & 1 deletion clients/ember/bin/vizzly-testem-launcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ let browserType = args[0];
let testUrl = args[args.length - 1];

// Validate arguments
if (!browserType || !testUrl || !testUrl.startsWith('http')) {
if (!browserType || !testUrl?.startsWith('http')) {
console.error('Usage: vizzly-testem-launcher <browser> <url>');
console.error(' browser: chromium | firefox | webkit');
console.error(' url: Test page URL (provided by Testem)');
Expand Down
2 changes: 1 addition & 1 deletion clients/static-site/src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export async function startStaticServer(rootDir, port = 0) {
* @returns {Promise<void>}
*/
export async function stopStaticServer(serverInfo) {
if (!serverInfo || !serverInfo.server) {
if (!serverInfo?.server) {
return;
}

Expand Down
2 changes: 1 addition & 1 deletion clients/storybook/src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export async function startStaticServer(rootDir, port = 0) {
* @returns {Promise<void>}
*/
export async function stopStaticServer(serverInfo) {
if (!serverInfo || !serverInfo.server) {
if (!serverInfo?.server) {
return;
}

Expand Down
1 change: 1 addition & 0 deletions src/api/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ export function buildScreenshotCheckObject(sha256, name, metadata = {}) {
browser: meta.browser || 'chrome',
viewport_width: meta.viewport?.width || meta.viewport_width || 1920,
viewport_height: meta.viewport?.height || meta.viewport_height || 1080,
properties: meta,
};
}

Expand Down
31 changes: 23 additions & 8 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ const formatHelp = (cmd, helper) => {

function extractGlobalOptionsFromArgv(argv, commandNames = null) {
let configPath = null;
let tokenArg = null;
let verboseMode = false;
let logLevelArg = null;
let jsonArg = null;
Expand All @@ -288,6 +289,12 @@ function extractGlobalOptionsFromArgv(argv, commandNames = null) {
configPath = argv[i + 1];
}

if (argv[i] === '--token' && argv[i + 1]) {
tokenArg = argv[i + 1];
} else if (argv[i].startsWith('--token=')) {
tokenArg = argv[i].substring('--token='.length);
}

if (argv[i] === '-v' || argv[i] === '--verbose') {
verboseMode = true;
}
Expand All @@ -310,7 +317,7 @@ function extractGlobalOptionsFromArgv(argv, commandNames = null) {
}
}

return { configPath, verboseMode, logLevelArg, jsonArg };
return { configPath, tokenArg, verboseMode, logLevelArg, jsonArg };
}

function normalizeJsonArgv(argv, commandNames) {
Expand Down Expand Up @@ -355,7 +362,7 @@ program

// Load plugins before defining commands
// We need to manually parse to get the config option early
let { configPath, verboseMode, logLevelArg, jsonArg } =
let { configPath, tokenArg, verboseMode, logLevelArg, jsonArg } =
extractGlobalOptionsFromArgv(process.argv);

// Configure output early
Expand All @@ -374,9 +381,9 @@ output.configure({
json: jsonArg,
});

const config = await loadConfig(configPath, {});
const services = createServices(config);
const pluginServices = createPluginServices(services);
let config = await loadConfig(configPath, { token: tokenArg });
let services = createServices(config);
let pluginServices = createPluginServices(services);

let plugins = [];
try {
Expand Down Expand Up @@ -424,7 +431,7 @@ program
program
.command('upload')
.description('Upload screenshots to Vizzly')
.argument('<path>', 'Path to screenshots directory or file')
.argument('<path>', 'Path to screenshots directory')
.option('-b, --build-name <name>', 'Build name for grouping')
.option('-m, --metadata <json>', 'Additional metadata as JSON')
.option('--batch-size <n>', 'Upload batch size', Number)
Expand All @@ -434,12 +441,17 @@ program
.option('--message <msg>', 'Commit message')
.option('--environment <env>', 'Environment name', 'test')
.option('--threshold <number>', 'Comparison threshold', Number)
.option(
'--min-cluster-size <pixels>',
'Minimum changed-pixel cluster size',
Number
)
.option('--token <token>', 'API token override')
.option('--wait', 'Wait for build completion')
.option('--upload-all', 'Upload all screenshots without SHA deduplication')
.option('--parallel-id <id>', 'Unique identifier for parallel test execution')
.action(async (path, options) => {
const globalOptions = program.opts();
let globalOptions = program.opts();

// Validate options
const validationErrors = validateUploadOptions(path, options);
Expand All @@ -451,7 +463,10 @@ program
process.exit(1);
}

await uploadCommand(path, options, globalOptions);
let result = await uploadCommand(path, options, globalOptions);
if (result?.exitCode) {
process.exit(result.exitCode);
}
});

// TDD command with subcommands - local visual review with an interactive dashboard
Expand Down
97 changes: 78 additions & 19 deletions src/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,23 @@ import {
isTddMode,
setVizzlyEnabled,
} from '../utils/environment-config.js';
import { createScreenshotProperties } from '../utils/screenshot-options.js';
import { normalizeScreenshotOptions } from '../utils/screenshot-options.js';

// Internal client state
let currentClient = null;
let currentServerUrl = null;
let isDisabled = false;
let currentFailOnDiff = false;

// Default timeout for screenshot requests (30 seconds)
let DEFAULT_TIMEOUT_MS = 30000;

// Log levels for client SDK output control
export let LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
export let LOG_LEVELS = Object.freeze({ debug: 0, info: 1, warn: 2, error: 3 });

function getEnvFailOnDiff(env = process.env) {
return env.VIZZLY_FAIL_ON_DIFF === 'true' || env.VIZZLY_FAIL_ON_DIFF === '1';
}

/**
* Check if client should log at the given level
Expand Down Expand Up @@ -59,6 +65,7 @@ function isVizzlyDisabled() {
function disableVizzly() {
isDisabled = true;
currentClient = null;
currentServerUrl = null;
}

/**
Expand All @@ -68,7 +75,11 @@ function disableVizzly() {
* @returns {string|null} Server URL if found
*/
export function autoDiscoverTddServer(startDir, deps = {}) {
let { exists = existsSync, readFile = readFileSync } = deps;
let {
exists = existsSync,
readFile = readFileSync,
env = process.env,
} = deps;
try {
// Look for .vizzly/server.json in current directory and parent directories
let currentDir = startDir || process.cwd();
Expand All @@ -82,6 +93,8 @@ export function autoDiscoverTddServer(startDir, deps = {}) {
let serverInfo = JSON.parse(readFile(serverJsonPath, 'utf8'));
if (serverInfo.port) {
let url = `http://localhost:${serverInfo.port}`;
currentFailOnDiff =
getEnvFailOnDiff(env) || Boolean(serverInfo.failOnDiff);
return url;
}
} catch {
Expand All @@ -108,6 +121,7 @@ function getClient() {

if (!currentClient) {
let serverUrl = getServerUrl();
currentFailOnDiff = getEnvFailOnDiff();

// Auto-detect local TDD server and enable Vizzly if TDD server is found
if (!serverUrl) {
Expand All @@ -120,7 +134,10 @@ function getClient() {

// If we have a server URL, create the client (regardless of initial enabled state)
if (serverUrl) {
currentClient = createSimpleClient(serverUrl);
currentServerUrl = serverUrl;
currentClient = createSimpleClient(serverUrl, {
failOnDiff: currentFailOnDiff,
});
}
}
return currentClient;
Expand Down Expand Up @@ -190,29 +207,41 @@ function httpPost(url, body, timeoutMs) {
* Create a simple HTTP client for screenshots
* @private
*/
function createSimpleClient(serverUrl) {
function createSimpleClient(serverUrl, clientOptions = {}) {
let { failOnDiff = false } = clientOptions;

return {
async screenshot(name, imageBuffer, options = {}) {
let normalizedOptions = normalizeScreenshotOptions(options);
let requestTimeout =
normalizedOptions.requestTimeout || DEFAULT_TIMEOUT_MS;

for (let warning of normalizedOptions.warnings) {
console.warn(`[vizzly] ${warning.message}`);
}

try {
// If it's a string, assume it's a file path and send directly
// Otherwise it's a Buffer, so convert to base64
let isFilePath = typeof imageBuffer === 'string';
let image = isFilePath ? imageBuffer : imageBuffer.toString('base64');
let type = isFilePath ? 'file-path' : 'base64';
let requestTimeout = options.requestTimeout || DEFAULT_TIMEOUT_MS;

let properties = createScreenshotProperties(options);
let screenshotData = {
buildId: normalizedOptions.buildId ?? getBuildId(),
name,
image,
type,
properties: normalizedOptions.properties,
};
if (normalizedOptions.warnings.length > 0) {
screenshotData.warnings = normalizedOptions.warnings;
}

let httpStart = Date.now();
let { status, json } = await httpPost(
`${serverUrl}/screenshot`,
{
buildId: getBuildId(),
name,
image,
type,
properties,
},
screenshotData,
requestTimeout
);
let httpMs = Date.now() - httpStart;
Expand All @@ -230,6 +259,12 @@ function createSimpleClient(serverUrl) {
if (status === 422 && json.tddMode && json.comparison) {
let comp = json.comparison;

if (failOnDiff) {
throw new Error(
`Visual diff detected for "${comp.name}" (${comp.diffPercentage ?? 0}% difference)`
);
}

// Return success so test continues and captures remaining screenshots
// Visual diff details will be shown in the summary after tests complete
return {
Expand All @@ -245,13 +280,23 @@ function createSimpleClient(serverUrl) {
);
}

if (
failOnDiff &&
json?.tddMode &&
['diff', 'failed'].includes(json.status)
) {
throw new Error(
`Visual diff detected for "${json.name || name}" (${json.diffPercentage ?? 0}% difference)`
);
}

return json;
} catch (error) {
// Handle timeout
if (error.message === 'Request timeout') {
if (shouldLogClient('error')) {
console.error(
`[vizzly] Screenshot timed out for "${name}" after ${DEFAULT_TIMEOUT_MS / 1000}s`
`[vizzly] Screenshot timed out for "${name}" after ${requestTimeout / 1000}s`
);
}
disableVizzly();
Expand Down Expand Up @@ -339,7 +384,7 @@ function createSimpleClient(serverUrl) {
* await vizzlyScreenshot('checkout-form', screenshot, {
* properties: {
* browser: 'chrome',
* viewport: '1920x1080'
* viewport: { width: 1920, height: 1080 }
* },
* threshold: 5
* });
Expand Down Expand Up @@ -398,10 +443,23 @@ export function isVizzlyReady() {
* @param {boolean} [config.enabled] - Enable/disable screenshots
*/
export function configure(config = {}) {
if ('failOnDiff' in config) {
currentFailOnDiff = config.failOnDiff === true;
} else if ('serverUrl' in config) {
currentFailOnDiff = getEnvFailOnDiff();
}

if ('serverUrl' in config) {
currentServerUrl = config.serverUrl || null;
currentClient = config.serverUrl
? createSimpleClient(config.serverUrl)
? createSimpleClient(config.serverUrl, {
failOnDiff: currentFailOnDiff,
})
: null;
} else if ('failOnDiff' in config && currentClient && currentServerUrl) {
currentClient = createSimpleClient(currentServerUrl, {
failOnDiff: currentFailOnDiff,
});
}

if (typeof config.enabled === 'boolean') {
Expand Down Expand Up @@ -430,10 +488,11 @@ export function getVizzlyInfo() {
let client = getClient();
return {
enabled: !isVizzlyDisabled(),
serverUrl: getServerUrl(),
serverUrl: currentServerUrl || getServerUrl() || null,
ready: !isVizzlyDisabled() && client !== null,
buildId: getBuildId(),
buildId: getBuildId() || null,
tddMode: isTddMode(),
disabled: isVizzlyDisabled(),
failOnDiff: currentFailOnDiff,
};
}
20 changes: 17 additions & 3 deletions src/commands/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export function appendApiQuery(endpoint, queryOption) {
return endpoint + (endpoint.includes('?') ? '&' : '?') + queryString;
}

export function validateApiRequest({ endpoint, method }) {
export function validateApiRequest({ endpoint, method, hasData = false }) {
let errors = [];

if (method !== 'GET' && method !== 'POST') {
Expand All @@ -100,13 +100,21 @@ export function validateApiRequest({ endpoint, method }) {
);
}

if (hasData && method !== 'POST') {
errors.push('Request data requires --method POST.');
}

return errors;
}

export function buildApiRequest({ endpoint, options = {} }) {
let normalizedEndpoint = normalizeApiEndpoint(endpoint);
let method = normalizeApiMethod(options.method || 'GET');
let errors = validateApiRequest({ endpoint: normalizedEndpoint, method });
let errors = validateApiRequest({
endpoint: normalizedEndpoint,
method,
hasData: options.data !== undefined,
});

if (errors.length > 0) {
return { errors, method, normalizedEndpoint, requestOptions: null };
Expand Down Expand Up @@ -265,7 +273,13 @@ export function validateApiOptions(endpoint, options = {}) {

let normalizedEndpoint = normalizeApiEndpoint(endpoint);
let method = normalizeApiMethod(options.method || 'GET');
errors.push(...validateApiRequest({ endpoint: normalizedEndpoint, method }));
errors.push(
...validateApiRequest({
endpoint: normalizedEndpoint,
method,
hasData: options.data !== undefined,
})
);

return errors;
}
Loading