@vizzly-testing/cli 0.33.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -3
- package/dist/api/core.js +2 -1
- package/dist/cli.js +21 -8
- package/dist/client/index.js +62 -21
- package/dist/commands/api.js +9 -3
- package/dist/commands/init.js +214 -2
- package/dist/commands/preview.js +13 -11
- package/dist/commands/project.js +1 -1
- package/dist/commands/run.js +18 -6
- package/dist/commands/tdd-daemon.js +8 -3
- package/dist/commands/tdd.js +24 -4
- package/dist/commands/upload.js +31 -13
- package/dist/reporter/reporter-bundle.iife.js +1 -1
- package/dist/sdk/index.js +23 -9
- package/dist/server/handlers/api-handler.js +3 -2
- package/dist/server/handlers/tdd-handler.js +5 -1
- package/dist/server/routers/screenshot.js +11 -4
- package/dist/services/build-manager.js +6 -1
- package/dist/test-runner/core.js +4 -1
- package/dist/test-runner/operations.js +2 -1
- package/dist/types/client.d.ts +32 -5
- package/dist/types/index.d.ts +33 -5
- package/dist/uploader/core.js +41 -9
- package/dist/uploader/operations.js +19 -17
- package/dist/utils/config-loader.js +12 -6
- package/dist/utils/context.js +46 -76
- package/dist/utils/environment-config.js +20 -0
- package/dist/utils/output.js +5 -5
- package/dist/utils/screenshot-options.js +60 -10
- package/package.json +2 -1
- package/skills/vizzly/SKILL.md +99 -0
- package/skills/vizzly/agents/openai.yaml +4 -0
- package/skills/vizzly/references/cli-context.md +42 -0
- package/skills/vizzly/references/dynamic-content.md +44 -0
- package/skills/vizzly/references/public-screenshots.md +44 -0
- package/skills/vizzly/references/sdks.md +62 -0
- package/skills/vizzly/references/setup-ci.md +42 -0
package/dist/commands/init.js
CHANGED
|
@@ -1,20 +1,29 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { createInterface } from 'node:readline/promises';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
4
6
|
import { z } from 'zod';
|
|
5
7
|
import { VizzlyError } from '../errors/vizzly-error.js';
|
|
6
8
|
import { loadPlugins } from '../plugin-loader.js';
|
|
7
9
|
import { loadConfig } from '../utils/config-loader.js';
|
|
8
10
|
import * as output from '../utils/output.js';
|
|
11
|
+
let commandDir = path.dirname(fileURLToPath(import.meta.url));
|
|
9
12
|
let configValueSchema = z.lazy(() => z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(configValueSchema), z.record(z.string(), configValueSchema)]));
|
|
10
13
|
let configSchemaValidator = z.record(z.string(), configValueSchema);
|
|
11
14
|
function createInitDeps(deps = {}) {
|
|
12
15
|
return {
|
|
13
16
|
access: deps.access || fs.access,
|
|
17
|
+
copy: deps.copy || fs.cp,
|
|
14
18
|
cwd: deps.cwd || (() => process.cwd()),
|
|
19
|
+
isInteractive: deps.isInteractive || (() => Boolean(process.stdin.isTTY && process.stdout.isTTY)),
|
|
15
20
|
loadConfig: deps.loadConfig || loadConfig,
|
|
16
21
|
loadPlugins: deps.loadPlugins || loadPlugins,
|
|
22
|
+
mkdir: deps.mkdir || fs.mkdir,
|
|
17
23
|
output: deps.output || output,
|
|
24
|
+
promptAgentSkill: deps.promptAgentSkill || promptAgentSkill,
|
|
25
|
+
readFile: deps.readFile || fs.readFile,
|
|
26
|
+
skillSourcePath: deps.skillSourcePath || getPackagedAgentSkillPath(),
|
|
18
27
|
writeFile: deps.writeFile || fs.writeFile
|
|
19
28
|
};
|
|
20
29
|
}
|
|
@@ -28,6 +37,15 @@ function configureOutput(output, options) {
|
|
|
28
37
|
export function getInitConfigPath(cwd = process.cwd()) {
|
|
29
38
|
return path.join(cwd, 'vizzly.config.js');
|
|
30
39
|
}
|
|
40
|
+
export function getPackagedAgentSkillPath(baseDir = commandDir) {
|
|
41
|
+
return path.resolve(baseDir, '..', '..', 'skills', 'vizzly');
|
|
42
|
+
}
|
|
43
|
+
export function getProjectAgentSkillPath(cwd = process.cwd()) {
|
|
44
|
+
return path.join(cwd, '.agents', 'skills', 'vizzly');
|
|
45
|
+
}
|
|
46
|
+
export function getProjectAgentsPath(cwd = process.cwd()) {
|
|
47
|
+
return path.join(cwd, 'AGENTS.md');
|
|
48
|
+
}
|
|
31
49
|
export async function fileExists(filePath, access = fs.access) {
|
|
32
50
|
try {
|
|
33
51
|
await access(filePath);
|
|
@@ -162,6 +180,169 @@ function showNextSteps(output) {
|
|
|
162
180
|
output.labelValue('Next steps', '');
|
|
163
181
|
output.list(['Set your API token: export VIZZLY_TOKEN="your-api-key"', 'Run your tests with Vizzly: pnpm exec vizzly run "pnpm test"', 'Upload screenshots: pnpm exec vizzly upload ./screenshots']);
|
|
164
182
|
}
|
|
183
|
+
async function promptAgentSkill({
|
|
184
|
+
stdin = process.stdin,
|
|
185
|
+
stdout = process.stdout
|
|
186
|
+
} = {}) {
|
|
187
|
+
let rl = createInterface({
|
|
188
|
+
input: stdin,
|
|
189
|
+
output: stdout
|
|
190
|
+
});
|
|
191
|
+
try {
|
|
192
|
+
let answer = await rl.question('Add the Vizzly agent skill to this repo? [Y/n] ');
|
|
193
|
+
return !['n', 'no'].includes(answer.trim().toLowerCase());
|
|
194
|
+
} finally {
|
|
195
|
+
rl.close();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async function shouldInstallAgentSkill(options, deps) {
|
|
199
|
+
if (options.agentGuidance) {
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
if (options.skipAgentSkill) {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
if (options.agentSkill) {
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
if (options.json || !deps.isInteractive()) {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
return await deps.promptAgentSkill();
|
|
212
|
+
}
|
|
213
|
+
let agentGuidanceStart = '<!-- vizzly-agent-guidance -->';
|
|
214
|
+
let agentGuidanceEnd = '<!-- /vizzly-agent-guidance -->';
|
|
215
|
+
export function createAgentGuidanceContent() {
|
|
216
|
+
return `${agentGuidanceStart}
|
|
217
|
+
## Visual Testing With Vizzly
|
|
218
|
+
|
|
219
|
+
Use Vizzly for user-facing UI changes when this repo has Vizzly configured.
|
|
220
|
+
|
|
221
|
+
- Read visual history before changing UI: \`npx vizzly context build current --source local --agent\`
|
|
222
|
+
- Inspect named screenshot history when useful: \`npx vizzly context screenshot "<screenshot-name>" --source local --json\`
|
|
223
|
+
- Verify visual changes through the existing user workflow that owns the surface: \`npx vizzly tdd run "<test command>" --no-open\`
|
|
224
|
+
|
|
225
|
+
Prefer existing E2E/user journeys over narrow screenshot-only specs. Treat Vizzly diffs as review evidence; do not approve or reject changes unless asked.
|
|
226
|
+
${agentGuidanceEnd}
|
|
227
|
+
`;
|
|
228
|
+
}
|
|
229
|
+
function appendSection(content, section) {
|
|
230
|
+
if (!content.trim()) {
|
|
231
|
+
return section;
|
|
232
|
+
}
|
|
233
|
+
return `${content.replace(/\s*$/, '')}\n\n${section}`;
|
|
234
|
+
}
|
|
235
|
+
export async function upsertProjectAgentGuidance({
|
|
236
|
+
cwd,
|
|
237
|
+
access = fs.access,
|
|
238
|
+
readFile = fs.readFile,
|
|
239
|
+
writeFile = fs.writeFile
|
|
240
|
+
}) {
|
|
241
|
+
let agentsPath = getProjectAgentsPath(cwd);
|
|
242
|
+
let guidance = createAgentGuidanceContent();
|
|
243
|
+
if (!(await fileExists(agentsPath, access))) {
|
|
244
|
+
await writeFile(agentsPath, guidance, 'utf8');
|
|
245
|
+
return {
|
|
246
|
+
status: 'created',
|
|
247
|
+
agentsPath
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
let existingContent = await readFile(agentsPath, 'utf8');
|
|
251
|
+
if (existingContent.includes(agentGuidanceStart)) {
|
|
252
|
+
return {
|
|
253
|
+
status: 'exists',
|
|
254
|
+
agentsPath
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
await writeFile(agentsPath, appendSection(existingContent, guidance), 'utf8');
|
|
258
|
+
return {
|
|
259
|
+
status: 'updated',
|
|
260
|
+
agentsPath
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
export async function installProjectAgentSkill({
|
|
264
|
+
cwd,
|
|
265
|
+
sourcePath,
|
|
266
|
+
access = fs.access,
|
|
267
|
+
copy = fs.cp,
|
|
268
|
+
mkdir = fs.mkdir
|
|
269
|
+
}) {
|
|
270
|
+
let targetPath = getProjectAgentSkillPath(cwd);
|
|
271
|
+
if (!(await fileExists(path.join(sourcePath, 'SKILL.md'), access))) {
|
|
272
|
+
return {
|
|
273
|
+
status: 'missing-source',
|
|
274
|
+
sourcePath,
|
|
275
|
+
targetPath
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
if (await fileExists(targetPath, access)) {
|
|
279
|
+
return {
|
|
280
|
+
status: 'exists',
|
|
281
|
+
sourcePath,
|
|
282
|
+
targetPath
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
await mkdir(path.dirname(targetPath), {
|
|
286
|
+
recursive: true
|
|
287
|
+
});
|
|
288
|
+
await copy(sourcePath, targetPath, {
|
|
289
|
+
recursive: true,
|
|
290
|
+
errorOnExist: true,
|
|
291
|
+
force: false
|
|
292
|
+
});
|
|
293
|
+
return {
|
|
294
|
+
status: 'installed',
|
|
295
|
+
sourcePath,
|
|
296
|
+
targetPath
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
function writeAgentSkillOutput(output, result) {
|
|
300
|
+
if (!result) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (result.status === 'installed') {
|
|
304
|
+
output.complete('Added Vizzly agent skill');
|
|
305
|
+
output.hint(`Installed at ${result.targetPath}`);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (result.status === 'exists') {
|
|
309
|
+
output.hint('Vizzly agent skill already exists in this repo');
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (result.status === 'missing-source') {
|
|
313
|
+
output.warn('Vizzly agent skill was not found in this CLI package');
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
function writeAgentGuidanceOutput(output, result) {
|
|
317
|
+
if (!result) {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (result.status === 'created') {
|
|
321
|
+
output.complete('Created AGENTS.md with Vizzly guidance');
|
|
322
|
+
output.hint(`Wrote ${result.agentsPath}`);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (result.status === 'updated') {
|
|
326
|
+
output.complete('Added Vizzly guidance to AGENTS.md');
|
|
327
|
+
output.hint(`Updated ${result.agentsPath}`);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (result.status === 'exists') {
|
|
331
|
+
output.hint('Vizzly guidance already exists in AGENTS.md');
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
function withAgentSetupResults(payload, {
|
|
335
|
+
agentSkillResult,
|
|
336
|
+
agentGuidanceResult
|
|
337
|
+
}) {
|
|
338
|
+
if (agentSkillResult) {
|
|
339
|
+
payload.agentSkill = agentSkillResult;
|
|
340
|
+
}
|
|
341
|
+
if (agentGuidanceResult) {
|
|
342
|
+
payload.agentGuidance = agentGuidanceResult;
|
|
343
|
+
}
|
|
344
|
+
return payload;
|
|
345
|
+
}
|
|
165
346
|
async function writeConfigFile({
|
|
166
347
|
configPath,
|
|
167
348
|
plugins,
|
|
@@ -197,14 +378,37 @@ export async function init(options = {}, deps = {}) {
|
|
|
197
378
|
let plugins = await loadInitPlugins(options, resolvedDeps);
|
|
198
379
|
let configPath = getInitConfigPath(resolvedDeps.cwd());
|
|
199
380
|
let hasConfig = await fileExists(configPath, resolvedDeps.access);
|
|
381
|
+
let agentSkillResult = null;
|
|
382
|
+
let agentGuidanceResult = null;
|
|
383
|
+
if (await shouldInstallAgentSkill(options, resolvedDeps)) {
|
|
384
|
+
agentSkillResult = await installProjectAgentSkill({
|
|
385
|
+
cwd: resolvedDeps.cwd(),
|
|
386
|
+
sourcePath: resolvedDeps.skillSourcePath,
|
|
387
|
+
access: resolvedDeps.access,
|
|
388
|
+
copy: resolvedDeps.copy,
|
|
389
|
+
mkdir: resolvedDeps.mkdir
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
if (options.agentGuidance) {
|
|
393
|
+
agentGuidanceResult = await upsertProjectAgentGuidance({
|
|
394
|
+
cwd: resolvedDeps.cwd(),
|
|
395
|
+
access: resolvedDeps.access,
|
|
396
|
+
readFile: resolvedDeps.readFile,
|
|
397
|
+
writeFile: resolvedDeps.writeFile
|
|
398
|
+
});
|
|
399
|
+
}
|
|
200
400
|
if (hasConfig && !options.force) {
|
|
201
401
|
if (options.json) {
|
|
202
|
-
|
|
402
|
+
let payload = withAgentSetupResults({
|
|
203
403
|
status: 'skipped',
|
|
204
404
|
reason: 'config_exists',
|
|
205
405
|
configPath,
|
|
206
406
|
message: 'A vizzly.config.js file already exists. Use --force to overwrite.'
|
|
407
|
+
}, {
|
|
408
|
+
agentSkillResult,
|
|
409
|
+
agentGuidanceResult
|
|
207
410
|
});
|
|
411
|
+
resolvedDeps.output.data(payload);
|
|
208
412
|
return {
|
|
209
413
|
status: 'skipped',
|
|
210
414
|
configPath
|
|
@@ -213,6 +417,8 @@ export async function init(options = {}, deps = {}) {
|
|
|
213
417
|
resolvedDeps.output.header('init');
|
|
214
418
|
resolvedDeps.output.warn('A vizzly.config.js file already exists');
|
|
215
419
|
resolvedDeps.output.hint('Use --force to overwrite');
|
|
420
|
+
writeAgentSkillOutput(resolvedDeps.output, agentSkillResult);
|
|
421
|
+
writeAgentGuidanceOutput(resolvedDeps.output, agentGuidanceResult);
|
|
216
422
|
return {
|
|
217
423
|
status: 'skipped',
|
|
218
424
|
configPath
|
|
@@ -228,11 +434,15 @@ export async function init(options = {}, deps = {}) {
|
|
|
228
434
|
});
|
|
229
435
|
let pluginNames = getPluginConfigNames(plugins);
|
|
230
436
|
if (options.json) {
|
|
231
|
-
|
|
437
|
+
let payload = withAgentSetupResults({
|
|
232
438
|
status: 'created',
|
|
233
439
|
configPath,
|
|
234
440
|
plugins: pluginNames
|
|
441
|
+
}, {
|
|
442
|
+
agentSkillResult,
|
|
443
|
+
agentGuidanceResult
|
|
235
444
|
});
|
|
445
|
+
resolvedDeps.output.data(payload);
|
|
236
446
|
return {
|
|
237
447
|
status: 'created',
|
|
238
448
|
configPath,
|
|
@@ -240,6 +450,8 @@ export async function init(options = {}, deps = {}) {
|
|
|
240
450
|
};
|
|
241
451
|
}
|
|
242
452
|
showNextSteps(resolvedDeps.output);
|
|
453
|
+
writeAgentSkillOutput(resolvedDeps.output, agentSkillResult);
|
|
454
|
+
writeAgentGuidanceOutput(resolvedDeps.output, agentGuidanceResult);
|
|
243
455
|
resolvedDeps.output.blank();
|
|
244
456
|
resolvedDeps.output.complete('Vizzly CLI setup complete');
|
|
245
457
|
return {
|
package/dist/commands/preview.js
CHANGED
|
@@ -259,6 +259,7 @@ export async function previewCommand(path, options = {}, globalOptions = {}, dep
|
|
|
259
259
|
formatSessionAge = defaultFormatSessionAge,
|
|
260
260
|
detectBranch = defaultDetectBranch,
|
|
261
261
|
openBrowser = defaultOpenBrowser,
|
|
262
|
+
createZipWithSystem: createZip = createZipWithSystem,
|
|
262
263
|
output = defaultOutput,
|
|
263
264
|
exit = code => process.exit(code)
|
|
264
265
|
} = deps;
|
|
@@ -284,19 +285,20 @@ export async function previewCommand(path, options = {}, globalOptions = {}, dep
|
|
|
284
285
|
reason: 'no-api-key'
|
|
285
286
|
};
|
|
286
287
|
}
|
|
288
|
+
let uploadPath = options.base ? resolve(path, options.base) : path;
|
|
287
289
|
|
|
288
290
|
// Validate path exists and is a directory
|
|
289
|
-
if (!existsSync(
|
|
290
|
-
output.error(`Path does not exist: ${
|
|
291
|
+
if (!existsSync(uploadPath)) {
|
|
292
|
+
output.error(`Path does not exist: ${uploadPath}`);
|
|
291
293
|
exit(1);
|
|
292
294
|
return {
|
|
293
295
|
success: false,
|
|
294
296
|
reason: 'path-not-found'
|
|
295
297
|
};
|
|
296
298
|
}
|
|
297
|
-
let pathStat = statSync(
|
|
299
|
+
let pathStat = statSync(uploadPath);
|
|
298
300
|
if (!pathStat.isDirectory()) {
|
|
299
|
-
output.error(`Path is not a directory: ${
|
|
301
|
+
output.error(`Path is not a directory: ${uploadPath}`);
|
|
300
302
|
exit(1);
|
|
301
303
|
return {
|
|
302
304
|
success: false,
|
|
@@ -455,14 +457,14 @@ export async function previewCommand(path, options = {}, globalOptions = {}, dep
|
|
|
455
457
|
count: fileCount,
|
|
456
458
|
totalSize,
|
|
457
459
|
files
|
|
458
|
-
} = await countFiles(
|
|
460
|
+
} = await countFiles(uploadPath, {
|
|
459
461
|
collectPaths: options.dryRun,
|
|
460
462
|
excludedDirs,
|
|
461
463
|
excludedFiles
|
|
462
464
|
});
|
|
463
465
|
if (fileCount === 0) {
|
|
464
466
|
output.stopSpinner();
|
|
465
|
-
output.error(`No files found in ${
|
|
467
|
+
output.error(`No files found in ${uploadPath}`);
|
|
466
468
|
exit(1);
|
|
467
469
|
return {
|
|
468
470
|
success: false,
|
|
@@ -477,7 +479,7 @@ export async function previewCommand(path, options = {}, globalOptions = {}, dep
|
|
|
477
479
|
if (globalOptions.json) {
|
|
478
480
|
output.data({
|
|
479
481
|
dryRun: true,
|
|
480
|
-
path: resolve(
|
|
482
|
+
path: resolve(uploadPath),
|
|
481
483
|
fileCount,
|
|
482
484
|
totalSize,
|
|
483
485
|
excludedDirs,
|
|
@@ -490,7 +492,7 @@ export async function previewCommand(path, options = {}, globalOptions = {}, dep
|
|
|
490
492
|
} else {
|
|
491
493
|
output.info(`Dry run - would upload ${fileCount} files (${formatBytes(totalSize)})`);
|
|
492
494
|
output.blank();
|
|
493
|
-
output.print(` ${colors.brand.textTertiary('Source')} ${resolve(
|
|
495
|
+
output.print(` ${colors.brand.textTertiary('Source')} ${resolve(uploadPath)}`);
|
|
494
496
|
output.print(` ${colors.brand.textTertiary('Files')} ${fileCount}`);
|
|
495
497
|
output.print(` ${colors.brand.textTertiary('Total size')} ${formatBytes(totalSize)}`);
|
|
496
498
|
output.blank();
|
|
@@ -538,7 +540,7 @@ export async function previewCommand(path, options = {}, globalOptions = {}, dep
|
|
|
538
540
|
let zipPath = join(tmpdir(), `vizzly-preview-${Date.now()}-${randomSuffix}.zip`);
|
|
539
541
|
let zipBuffer;
|
|
540
542
|
try {
|
|
541
|
-
await
|
|
543
|
+
await createZip(uploadPath, zipPath, exclusions);
|
|
542
544
|
zipBuffer = await readFile(zipPath);
|
|
543
545
|
} catch (zipError) {
|
|
544
546
|
output.stopSpinner();
|
|
@@ -569,10 +571,10 @@ export async function previewCommand(path, options = {}, globalOptions = {}, dep
|
|
|
569
571
|
success: true,
|
|
570
572
|
buildId,
|
|
571
573
|
previewUrl: result.previewUrl,
|
|
572
|
-
files: result.uploaded
|
|
574
|
+
files: result.uploaded ?? fileCount,
|
|
573
575
|
bytes: totalSize,
|
|
574
576
|
compressedBytes: zipBuffer.length,
|
|
575
|
-
compressionRatio
|
|
577
|
+
compressionRatio: `${compressionPercent}%`,
|
|
576
578
|
newBytes: result.newBytes,
|
|
577
579
|
reusedBlobs: result.reusedBlobs || 0,
|
|
578
580
|
deduplicationRatio: result.deduplicationRatio,
|
package/dist/commands/project.js
CHANGED
|
@@ -56,7 +56,7 @@ export async function projectLinkCommand(selector, options = {}, globalOptions =
|
|
|
56
56
|
} = parseProjectSelector(selector, options);
|
|
57
57
|
try {
|
|
58
58
|
let config = await loadConfig(globalOptions.config, globalOptions);
|
|
59
|
-
let userToken = config.userToken || (await getAccessToken());
|
|
59
|
+
let userToken = config.userToken || globalOptions.token || (await getAccessToken());
|
|
60
60
|
if (!userToken) {
|
|
61
61
|
output.error('Login required before linking a project');
|
|
62
62
|
output.hint('Run "vizzly login" first, then try project link again');
|
package/dist/commands/run.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { spawn as defaultSpawn } from 'node:child_process';
|
|
7
7
|
import { createBuild as defaultCreateApiBuild, createApiClient as defaultCreateApiClient, finalizeBuild as defaultFinalizeApiBuild, getBuild as defaultGetBuild, getTokenContext as defaultGetTokenContext } from '../api/index.js';
|
|
8
|
+
import { CONFIG_DEFAULTS } from '../config/core.js';
|
|
8
9
|
import { VizzlyError } from '../errors/vizzly-error.js';
|
|
9
10
|
import { createServerManager as defaultCreateServerManager } from '../server-manager/index.js';
|
|
10
11
|
import { createBuildObject as defaultCreateBuildObject } from '../services/build-manager.js';
|
|
@@ -174,10 +175,11 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
|
|
|
174
175
|
}
|
|
175
176
|
|
|
176
177
|
// Collect git metadata and build info
|
|
177
|
-
let
|
|
178
|
-
let
|
|
179
|
-
let
|
|
180
|
-
let
|
|
178
|
+
let configuredBuildName = config.build.name && config.build.name !== CONFIG_DEFAULTS.build.name ? config.build.name : undefined;
|
|
179
|
+
let branch = await detectBranch(options.branch || config.build.branch);
|
|
180
|
+
let commit = await detectCommit(options.commit || config.build.commit);
|
|
181
|
+
let message = options.message || config.build.message || (await detectCommitMessage());
|
|
182
|
+
let buildName = await generateBuildNameWithGit(options.buildName || configuredBuildName);
|
|
181
183
|
let pullRequestNumber = detectPullRequestNumber();
|
|
182
184
|
if (globalOptions.verbose) {
|
|
183
185
|
output.info('Configuration loaded');
|
|
@@ -305,7 +307,7 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
|
|
|
305
307
|
}
|
|
306
308
|
|
|
307
309
|
// JSON output mode - output structured data and exit
|
|
308
|
-
if (globalOptions.json) {
|
|
310
|
+
if (globalOptions.json && !runOptions.wait) {
|
|
309
311
|
let executionTimeMs = Date.now() - startTime;
|
|
310
312
|
let displayUrl = await resolveBuildDisplayUrl({
|
|
311
313
|
result,
|
|
@@ -490,7 +492,17 @@ export async function runCommand(testCommand, options = {}, globalOptions = {},
|
|
|
490
492
|
// Don't fail CI for Vizzly infrastructure issues (5xx errors)
|
|
491
493
|
let status = error.context?.status;
|
|
492
494
|
if (status >= 500) {
|
|
493
|
-
|
|
495
|
+
if (globalOptions.json) {
|
|
496
|
+
output.data({
|
|
497
|
+
buildId: null,
|
|
498
|
+
status: 'skipped',
|
|
499
|
+
message: 'Vizzly API unavailable - visual tests skipped',
|
|
500
|
+
executionTimeMs: Date.now() - (startTime || Date.now())
|
|
501
|
+
});
|
|
502
|
+
} else {
|
|
503
|
+
output.warn('Vizzly API unavailable - visual tests skipped. Your tests still ran.');
|
|
504
|
+
}
|
|
505
|
+
output.cleanup();
|
|
494
506
|
output.debug('api', 'API error details:', {
|
|
495
507
|
error: error.message
|
|
496
508
|
});
|
|
@@ -43,17 +43,20 @@ export function cleanupLocalDaemonFiles(directory = process.cwd(), deps = {}) {
|
|
|
43
43
|
export function buildLegacyServerInfo({
|
|
44
44
|
pid,
|
|
45
45
|
port,
|
|
46
|
+
failOnDiff = false,
|
|
46
47
|
now = Date.now
|
|
47
48
|
}) {
|
|
48
49
|
return {
|
|
49
50
|
pid,
|
|
50
51
|
port: port.toString(),
|
|
51
|
-
startTime: now()
|
|
52
|
+
startTime: now(),
|
|
53
|
+
failOnDiff
|
|
52
54
|
};
|
|
53
55
|
}
|
|
54
56
|
export function writeLegacyGlobalServerFile({
|
|
55
57
|
pid,
|
|
56
|
-
port
|
|
58
|
+
port,
|
|
59
|
+
failOnDiff = false
|
|
57
60
|
}, {
|
|
58
61
|
home = homedir,
|
|
59
62
|
exists = existsSync,
|
|
@@ -71,6 +74,7 @@ export function writeLegacyGlobalServerFile({
|
|
|
71
74
|
let serverInfo = buildLegacyServerInfo({
|
|
72
75
|
pid,
|
|
73
76
|
port,
|
|
77
|
+
failOnDiff,
|
|
74
78
|
now
|
|
75
79
|
});
|
|
76
80
|
writeFile(globalServerFile, JSON.stringify(serverInfo, null, 2));
|
|
@@ -398,7 +402,8 @@ export async function tddStartCommand(options = {}, globalOptions = {}) {
|
|
|
398
402
|
try {
|
|
399
403
|
writeLegacyGlobalServerFile({
|
|
400
404
|
pid: child.pid,
|
|
401
|
-
port
|
|
405
|
+
port,
|
|
406
|
+
failOnDiff: options.failOnDiff || false
|
|
402
407
|
});
|
|
403
408
|
} catch {
|
|
404
409
|
// Non-fatal, SDK can still use health check
|
package/dist/commands/tdd.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { spawn as defaultSpawn } from 'node:child_process';
|
|
7
7
|
import { createBuild as defaultCreateApiBuild, createApiClient as defaultCreateApiClient, finalizeBuild as defaultFinalizeApiBuild, getBuild as defaultGetBuild } from '../api/index.js';
|
|
8
|
+
import { CONFIG_DEFAULTS } from '../config/core.js';
|
|
8
9
|
import { VizzlyError } from '../errors/vizzly-error.js';
|
|
9
10
|
import { createServerManager as defaultCreateServerManager } from '../server-manager/index.js';
|
|
10
11
|
import { createAuthService as defaultCreateAuthService } from '../services/auth-service.js';
|
|
@@ -13,7 +14,7 @@ import { createConfigService as defaultCreateConfigService } from '../services/c
|
|
|
13
14
|
import { createProjectService as defaultCreateProjectService } from '../services/project-service.js';
|
|
14
15
|
import { initializeDaemon as defaultInitializeDaemon, runTests as defaultRunTests } from '../test-runner/index.js';
|
|
15
16
|
import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
|
|
16
|
-
import { detectBranch as defaultDetectBranch, detectCommit as defaultDetectCommit } from '../utils/git.js';
|
|
17
|
+
import { detectBranch as defaultDetectBranch, detectCommit as defaultDetectCommit, detectCommitMessage as defaultDetectCommitMessage, detectPullRequestNumber as defaultDetectPullRequestNumber, generateBuildNameWithGit as defaultGenerateBuildNameWithGit } from '../utils/git.js';
|
|
17
18
|
import * as defaultOutput from '../utils/output.js';
|
|
18
19
|
function buildLocalContextCommand() {
|
|
19
20
|
return 'vizzly context build current --source local --agent';
|
|
@@ -43,6 +44,9 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {},
|
|
|
43
44
|
runTests = defaultRunTests,
|
|
44
45
|
detectBranch = defaultDetectBranch,
|
|
45
46
|
detectCommit = defaultDetectCommit,
|
|
47
|
+
detectCommitMessage = defaultDetectCommitMessage,
|
|
48
|
+
detectPullRequestNumber = defaultDetectPullRequestNumber,
|
|
49
|
+
generateBuildNameWithGit = defaultGenerateBuildNameWithGit,
|
|
46
50
|
spawn = defaultSpawn,
|
|
47
51
|
output = defaultOutput
|
|
48
52
|
} = deps;
|
|
@@ -76,7 +80,7 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {},
|
|
|
76
80
|
let config = await loadConfig(globalOptions.config, allOptions);
|
|
77
81
|
|
|
78
82
|
// Dev mode works locally by default - only needs token for baseline download
|
|
79
|
-
let needsToken = options.baselineBuild || options.baselineComparison;
|
|
83
|
+
let needsToken = options.baselineBuild || options.baselineComparison || config.baselineBuildId || config.baselineComparisonId;
|
|
80
84
|
if (!config.apiKey && needsToken) {
|
|
81
85
|
throw new Error('API token required when using --baseline-build or --baseline-comparison flags');
|
|
82
86
|
}
|
|
@@ -85,8 +89,12 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {},
|
|
|
85
89
|
config.allowNoToken = true;
|
|
86
90
|
|
|
87
91
|
// Collect git metadata
|
|
88
|
-
let
|
|
89
|
-
let
|
|
92
|
+
let configuredBuildName = config.build.name && config.build.name !== CONFIG_DEFAULTS.build.name ? config.build.name : undefined;
|
|
93
|
+
let branch = await detectBranch(options.branch || config.build.branch);
|
|
94
|
+
let commit = await detectCommit(options.commit || config.build.commit);
|
|
95
|
+
let message = options.message || config.build.message || (await detectCommitMessage());
|
|
96
|
+
let buildName = await generateBuildNameWithGit(options.buildName || configuredBuildName);
|
|
97
|
+
let pullRequestNumber = detectPullRequestNumber();
|
|
90
98
|
|
|
91
99
|
// Show header (skip in daemon mode)
|
|
92
100
|
if (!options.daemon) {
|
|
@@ -132,9 +140,21 @@ export async function tddCommand(testCommand, options = {}, globalOptions = {},
|
|
|
132
140
|
tdd: true,
|
|
133
141
|
daemon: options.daemon || false,
|
|
134
142
|
setBaseline: options.setBaseline || false,
|
|
143
|
+
failOnDiff: options.failOnDiff || false,
|
|
144
|
+
name: buildName,
|
|
145
|
+
buildName,
|
|
135
146
|
branch,
|
|
136
147
|
commit,
|
|
148
|
+
message,
|
|
137
149
|
environment: config.build.environment,
|
|
150
|
+
pullRequestNumber,
|
|
151
|
+
parallelId: config.parallelId,
|
|
152
|
+
metadata: {
|
|
153
|
+
comparison: {
|
|
154
|
+
threshold: config.comparison.threshold,
|
|
155
|
+
minClusterSize: config.comparison.minClusterSize
|
|
156
|
+
}
|
|
157
|
+
},
|
|
138
158
|
threshold: config.comparison.threshold,
|
|
139
159
|
minClusterSize: config.comparison.minClusterSize,
|
|
140
160
|
allowNoToken: config.allowNoToken || false,
|
package/dist/commands/upload.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createApiClient as defaultCreateApiClient, finalizeBuild as defaultFinalizeBuild, getTokenContext as defaultGetTokenContext } from '../api/index.js';
|
|
2
|
+
import { CONFIG_DEFAULTS } from '../config/core.js';
|
|
2
3
|
import { createUploader as defaultCreateUploader } from '../uploader/index.js';
|
|
3
4
|
import { getAppBaseUrl } from '../utils/api-url.js';
|
|
4
5
|
import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
|
|
@@ -73,12 +74,12 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
|
|
|
73
74
|
});
|
|
74
75
|
let buildId = null;
|
|
75
76
|
let config = null;
|
|
76
|
-
|
|
77
|
+
let uploadStartTime = Date.now();
|
|
77
78
|
try {
|
|
78
79
|
output.info('Starting upload process...');
|
|
79
80
|
|
|
80
81
|
// Load configuration with CLI overrides
|
|
81
|
-
|
|
82
|
+
let allOptions = {
|
|
82
83
|
...globalOptions,
|
|
83
84
|
...options
|
|
84
85
|
};
|
|
@@ -95,11 +96,12 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
|
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
// Collect git metadata if not provided
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
99
|
+
let configuredBuildName = config.build.name && config.build.name !== CONFIG_DEFAULTS.build.name ? config.build.name : undefined;
|
|
100
|
+
let branch = await detectBranch(options.branch || config.build.branch);
|
|
101
|
+
let commit = await detectCommit(options.commit || config.build.commit);
|
|
102
|
+
let message = options.message || config.build.message || (await detectCommitMessage());
|
|
103
|
+
let buildName = await generateBuildNameWithGit(options.buildName || configuredBuildName);
|
|
104
|
+
let pullRequestNumber = detectPullRequestNumber();
|
|
103
105
|
output.info(`Uploading screenshots from: ${screenshotsPath}`);
|
|
104
106
|
if (globalOptions.verbose) {
|
|
105
107
|
output.info('Configuration loaded');
|
|
@@ -119,7 +121,7 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
|
|
|
119
121
|
});
|
|
120
122
|
|
|
121
123
|
// Prepare upload options with progress callback
|
|
122
|
-
|
|
124
|
+
let uploadOptions = {
|
|
123
125
|
screenshotsDir: screenshotsPath,
|
|
124
126
|
buildName,
|
|
125
127
|
branch,
|
|
@@ -127,12 +129,13 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
|
|
|
127
129
|
message,
|
|
128
130
|
environment: config.build.environment,
|
|
129
131
|
threshold: config.comparison.threshold,
|
|
132
|
+
minClusterSize: config.comparison.minClusterSize,
|
|
130
133
|
uploadAll: options.uploadAll || false,
|
|
131
134
|
metadata: options.metadata ? JSON.parse(options.metadata) : {},
|
|
132
135
|
pullRequestNumber,
|
|
133
136
|
parallelId: config.parallelId,
|
|
134
137
|
onProgress: progressData => {
|
|
135
|
-
|
|
138
|
+
let {
|
|
136
139
|
message: progressMessage,
|
|
137
140
|
current,
|
|
138
141
|
total,
|
|
@@ -158,7 +161,7 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
|
|
|
158
161
|
|
|
159
162
|
// Start upload
|
|
160
163
|
output.progress('Starting upload...');
|
|
161
|
-
|
|
164
|
+
let result = await uploader.upload(uploadOptions);
|
|
162
165
|
buildId = result.buildId; // Ensure we have the buildId
|
|
163
166
|
|
|
164
167
|
// Write session for subsequent commands (like preview)
|
|
@@ -263,8 +266,10 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
|
|
|
263
266
|
executionTimeMs
|
|
264
267
|
});
|
|
265
268
|
output.cleanup();
|
|
269
|
+
let success = buildResult.failedComparisons === 0;
|
|
266
270
|
return {
|
|
267
|
-
success
|
|
271
|
+
success,
|
|
272
|
+
exitCode: success ? 0 : 1,
|
|
268
273
|
result
|
|
269
274
|
};
|
|
270
275
|
}
|
|
@@ -283,6 +288,14 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
|
|
|
283
288
|
let waitBuildUrl = buildResult.url || (await buildUrlConstructor(result.buildId, config.apiUrl, config.apiKey, deps));
|
|
284
289
|
output.blank();
|
|
285
290
|
output.labelValue('View', output.link('Results', waitBuildUrl));
|
|
291
|
+
if (buildResult.failedComparisons > 0) {
|
|
292
|
+
output.cleanup();
|
|
293
|
+
return {
|
|
294
|
+
success: false,
|
|
295
|
+
exitCode: 1,
|
|
296
|
+
result
|
|
297
|
+
};
|
|
298
|
+
}
|
|
286
299
|
}
|
|
287
300
|
output.cleanup();
|
|
288
301
|
return {
|
|
@@ -300,11 +313,10 @@ export async function uploadCommand(screenshotsPath, options = {}, globalOptions
|
|
|
300
313
|
message: 'Vizzly API unavailable - upload skipped',
|
|
301
314
|
executionTimeMs: Date.now() - uploadStartTime
|
|
302
315
|
});
|
|
303
|
-
output.cleanup();
|
|
304
316
|
} else {
|
|
305
317
|
output.warn('Vizzly API unavailable - upload skipped. Your tests still ran.');
|
|
306
|
-
output.cleanup();
|
|
307
318
|
}
|
|
319
|
+
output.cleanup();
|
|
308
320
|
return {
|
|
309
321
|
success: true,
|
|
310
322
|
result: {
|
|
@@ -381,6 +393,12 @@ export function validateUploadOptions(screenshotsPath, options) {
|
|
|
381
393
|
errors.push('Threshold must be a non-negative number (CIEDE2000 Delta E)');
|
|
382
394
|
}
|
|
383
395
|
}
|
|
396
|
+
if (options.minClusterSize !== undefined) {
|
|
397
|
+
let minClusterSize = Number(options.minClusterSize);
|
|
398
|
+
if (!Number.isInteger(minClusterSize) || minClusterSize < 1) {
|
|
399
|
+
errors.push('Min cluster size must be a positive integer');
|
|
400
|
+
}
|
|
401
|
+
}
|
|
384
402
|
if (options.batchSize !== undefined) {
|
|
385
403
|
let n = Number(options.batchSize);
|
|
386
404
|
if (!Number.isInteger(n) || n <= 0) {
|