@vizzly-testing/cli 0.38.1 → 0.39.1

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.
Files changed (37) hide show
  1. package/README.md +222 -195
  2. package/dist/cli.js +55 -54
  3. package/dist/commands/init.js +154 -483
  4. package/dist/commands/project.js +128 -44
  5. package/dist/commands/run.js +2 -2
  6. package/dist/commands/tdd.js +89 -38
  7. package/dist/commands/upload.js +2 -2
  8. package/dist/init/agents.js +140 -0
  9. package/dist/init/core.js +31 -0
  10. package/dist/init/prompts.js +44 -0
  11. package/dist/reporter/reporter-bundle.css +1 -1
  12. package/dist/reporter/reporter-bundle.iife.js +30 -61
  13. package/dist/reporter-ssr/ssr-entry.js +3 -6
  14. package/dist/server/handlers/tdd-handler.js +33 -2
  15. package/dist/services/index.js +8 -2
  16. package/dist/tdd/services/comparison-service.js +4 -1
  17. package/dist/tdd/tdd-service.js +14 -2
  18. package/dist/test-runner/core.js +28 -3
  19. package/dist/test-runner/operations.js +41 -20
  20. package/dist/types/index.d.ts +8 -0
  21. package/dist/uploader/core.js +1 -0
  22. package/dist/utils/cli-update.js +105 -0
  23. package/dist/utils/config-discovery.js +73 -0
  24. package/dist/utils/config-loader.js +29 -8
  25. package/dist/utils/config-schema.js +5 -0
  26. package/dist/utils/file-changes.js +105 -0
  27. package/dist/utils/project-binding.js +96 -0
  28. package/dist/utils/project-link-store.js +13 -7
  29. package/dist/utils/update-check.js +2 -0
  30. package/dist/utils/update-notifications.js +175 -0
  31. package/package.json +16 -16
  32. package/skills/vizzly/SKILL.md +4 -3
  33. package/skills/vizzly/references/ci.md +33 -0
  34. package/skills/vizzly/references/cli-context.md +81 -71
  35. package/skills/vizzly/references/dynamic-content.md +16 -17
  36. package/skills/vizzly/references/sdks.md +22 -22
  37. package/skills/vizzly/references/setup-ci.md +0 -44
@@ -0,0 +1,105 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { access, lstat, mkdir, rename, rm, writeFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { readOptionalFile } from './config-discovery.js';
6
+ export async function fileChange(filename, after) {
7
+ let before = await readOptionalFile(filename);
8
+ return before === after ? null : {
9
+ path: filename,
10
+ before,
11
+ after
12
+ };
13
+ }
14
+ async function writableParent(filename) {
15
+ let parent = path.dirname(filename);
16
+ while (true) {
17
+ try {
18
+ await access(parent, constants.W_OK);
19
+ return;
20
+ } catch (error) {
21
+ if (error.code !== 'ENOENT' || path.dirname(parent) === parent) {
22
+ throw error;
23
+ }
24
+ parent = path.dirname(parent);
25
+ }
26
+ }
27
+ }
28
+ async function checkChange(change) {
29
+ // Reject redirects in parent folders too, including explicit config paths.
30
+ // Top-level OS aliases such as /tmp on macOS are safe to follow.
31
+ for (let parent = path.dirname(change.path); path.dirname(parent) !== parent; parent = path.dirname(parent)) {
32
+ try {
33
+ if (path.dirname(parent) !== path.parse(parent).root && (await lstat(parent)).isSymbolicLink()) {
34
+ throw new Error(`Setup will not write through a symlink: ${parent}`);
35
+ }
36
+ } catch (error) {
37
+ if (error.code !== 'ENOENT') throw error;
38
+ }
39
+ }
40
+ let info;
41
+ try {
42
+ info = await lstat(change.path);
43
+ } catch (error) {
44
+ if (error.code !== 'ENOENT') throw error;
45
+ }
46
+ if (info?.isSymbolicLink()) {
47
+ throw new Error(`Setup will not replace a symlink: ${change.path}`);
48
+ }
49
+ let current = await readOptionalFile(change.path);
50
+ if (current !== change.before) {
51
+ throw new Error(`${change.path} changed during setup; run init again.`);
52
+ }
53
+ if (current !== null) {
54
+ if (!info.isFile()) {
55
+ throw new Error(`Setup needs a regular file: ${change.path}`);
56
+ }
57
+ await access(change.path, constants.W_OK);
58
+ }
59
+ await writableParent(change.path);
60
+ }
61
+ export async function preflightChanges(changes) {
62
+ for (let change of changes.filter(Boolean)) await checkChange(change);
63
+ }
64
+ export async function applyChanges(changes) {
65
+ let planned = changes.filter(Boolean);
66
+ await preflightChanges(planned);
67
+ let completed = [];
68
+ try {
69
+ for (let change of planned) {
70
+ await checkChange(change);
71
+ await mkdir(path.dirname(change.path), {
72
+ recursive: true
73
+ });
74
+ if (change.before === null) {
75
+ await writeFile(change.path, change.after, {
76
+ flag: 'wx'
77
+ });
78
+ } else {
79
+ let temporary = `${change.path}.${randomUUID()}.tmp`;
80
+ try {
81
+ let {
82
+ mode
83
+ } = await lstat(change.path);
84
+ await writeFile(temporary, change.after, {
85
+ flag: 'wx',
86
+ mode
87
+ });
88
+ await rename(temporary, change.path);
89
+ } finally {
90
+ await rm(temporary, {
91
+ force: true
92
+ });
93
+ }
94
+ }
95
+ completed.push({
96
+ path: change.path,
97
+ status: 'written'
98
+ });
99
+ }
100
+ } catch (error) {
101
+ error.completed = completed;
102
+ throw error;
103
+ }
104
+ return completed;
105
+ }
@@ -0,0 +1,96 @@
1
+ import path from 'node:path';
2
+ import { VizzlyError } from '../errors/vizzly-error.js';
3
+ import { discoverConfig } from './config-discovery.js';
4
+ import { applyChanges } from './file-changes.js';
5
+ function sameProject(left, right) {
6
+ return left?.organizationSlug === right.organizationSlug && left?.projectSlug === right.projectSlug;
7
+ }
8
+ function bindingValues(project, apiUrl) {
9
+ return {
10
+ ...(apiUrl !== 'https://app.vizzly.dev' ? {
11
+ apiUrl
12
+ } : {}),
13
+ project
14
+ };
15
+ }
16
+ function javascriptBinding(values) {
17
+ return `export default ${JSON.stringify(values, null, 2)};\n`;
18
+ }
19
+ export async function planProjectBinding({
20
+ cwd = process.cwd(),
21
+ configPath = null,
22
+ project,
23
+ apiUrl = 'https://app.vizzly.dev',
24
+ currentProject,
25
+ currentApiUrl
26
+ }) {
27
+ let config = await discoverConfig({
28
+ cwd,
29
+ configPath
30
+ });
31
+ let filename = config.configPath || path.join(cwd, 'vizzly.config.js');
32
+ let values = bindingValues(project, apiUrl);
33
+ let plan = {
34
+ status: 'ready',
35
+ configPath: filename,
36
+ before: config.source,
37
+ after: null
38
+ };
39
+ if (config.exists && sameProject(currentProject, project) && currentApiUrl === apiUrl) {
40
+ return plan;
41
+ }
42
+ if (!config.exists) {
43
+ if (['javascript', 'typescript'].includes(config.format)) {
44
+ plan.after = javascriptBinding(values);
45
+ } else if (config.format === 'json') {
46
+ plan.after = `${JSON.stringify(values, null, 2)}\n`;
47
+ } else {
48
+ throw new VizzlyError(`Cannot create a project binding in ${filename}. Use a JavaScript, TypeScript, or JSON config.`, 'PROJECT_CONFIG_FORMAT');
49
+ }
50
+ return plan;
51
+ }
52
+ if (config.format === 'package' || config.format === 'json') {
53
+ let document = JSON.parse(config.source);
54
+ let existing = config.format === 'package' ? document.vizzly || {} : document;
55
+ let updated = {
56
+ ...existing,
57
+ ...values
58
+ };
59
+ if (apiUrl === 'https://app.vizzly.dev' && existing.apiUrl) {
60
+ updated.apiUrl = apiUrl;
61
+ }
62
+ let result = config.format === 'package' ? {
63
+ ...document,
64
+ vizzly: updated
65
+ } : updated;
66
+ let indent = config.source.match(/\n([ \t]+)\S/)?.[1] || ' ';
67
+ plan.after = `${JSON.stringify(result, null, indent)}\n`;
68
+ return plan;
69
+ }
70
+
71
+ // A freshly scaffolded config contains only comments. Add the binding without
72
+ // interpreting or rewriting executable configuration supplied by the user.
73
+ let comments = String.raw`(?:\s|\/\/[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)*`;
74
+ let emptyModule = new RegExp(`^${comments}export\\s+default\\s*\\{${comments}\\}\\s*;?\\s*$`);
75
+ if (['javascript', 'typescript'].includes(config.format) && emptyModule.test(config.source)) {
76
+ let propertyText = JSON.stringify(values, null, 2).slice(1, -1);
77
+ let closingBrace = config.source.lastIndexOf('}');
78
+ plan.after = `${config.source.slice(0, closingBrace).trimEnd()}\n${propertyText}\n${config.source.slice(closingBrace)}`;
79
+ return plan;
80
+ }
81
+ return {
82
+ ...plan,
83
+ status: 'manual',
84
+ instructions: `Add these fields to ${filename}, then run project link again:\n${JSON.stringify(values, null, 2)}`
85
+ };
86
+ }
87
+ export async function writeProjectBinding(plan) {
88
+ if (plan.status !== 'ready' || plan.after === null) {
89
+ return;
90
+ }
91
+ await applyChanges([{
92
+ path: plan.configPath,
93
+ before: plan.before,
94
+ after: plan.after
95
+ }]);
96
+ }
@@ -49,25 +49,31 @@ export async function saveProjectLink(link, deps = {}) {
49
49
  token: link.token
50
50
  };
51
51
  }
52
- export async function getActiveProjectLink(options = {}, deps = {}) {
52
+ export async function getProjectLink(options = {}, deps = {}) {
53
53
  let {
54
54
  loadConfig = loadGlobalConfig,
55
- getSecret = defaultGetSecret
55
+ getSecret = defaultGetSecret,
56
+ now = Date.now
56
57
  } = deps;
58
+ if (!options.account && !(options.organizationSlug && options.projectSlug)) {
59
+ return null;
60
+ }
57
61
  let config = await loadConfig();
58
62
  let projectLink = getProjectLinkConfig(config);
59
- let account = options.account || projectLink.active;
63
+ let account = options.account || buildProjectLinkAccount(options);
60
64
  let link = account ? projectLink.links?.[account] : null;
61
- if (!link && options.apiUrl && options.organizationSlug && options.projectSlug) {
62
- account = buildProjectLinkAccount(options);
63
- link = projectLink.links?.[account] || null;
64
- }
65
65
  if (!link) {
66
66
  return null;
67
67
  }
68
68
  if (options.apiUrl && link.apiUrl && link.apiUrl !== options.apiUrl) {
69
69
  return null;
70
70
  }
71
+ if (link.expiresAt) {
72
+ let expiresAt = Date.parse(link.expiresAt);
73
+ if (!Number.isFinite(expiresAt) || expiresAt <= now()) {
74
+ return null;
75
+ }
76
+ }
71
77
  let token = link.storage === 'keychain' ? await getSecret(account) : link.token;
72
78
  if (!token) {
73
79
  return null;
@@ -0,0 +1,2 @@
1
+ import { checkForUpdate } from './update-notifications.js';
2
+ await checkForUpdate();
@@ -0,0 +1,175 @@
1
+ import { spawn } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { agentGuidanceFlag, agentGuidancePath, bundledAgentSkill, planAgentSetup } from '../init/agents.js';
5
+ import { createPrompts } from '../init/prompts.js';
6
+ import { findCliUpdatePlan, runUpdateProcess } from './cli-update.js';
7
+ import { readOptionalFile } from './config-discovery.js';
8
+ import { applyChanges, fileChange, preflightChanges } from './file-changes.js';
9
+ import { getGlobalConfigDir } from './global-config.js';
10
+ import * as output from './output.js';
11
+ import { getPackageName, getPackageVersion } from './package-info.js';
12
+ let day = 24 * 60 * 60 * 1000;
13
+ async function readState(filename) {
14
+ try {
15
+ let state = JSON.parse(await readOptionalFile(filename));
16
+ return state && typeof state === 'object' && !Array.isArray(state) ? state : {};
17
+ } catch {
18
+ return {};
19
+ }
20
+ }
21
+ async function stateChange(filename, state) {
22
+ return fileChange(filename, `${JSON.stringify(state, null, 2)}\n`);
23
+ }
24
+
25
+ // The registry's latest tag contains stable releases. Never suggest a downgrade
26
+ // or a prerelease, even when an unexpected registry response is cached.
27
+ export function isNewerStableVersion(candidate, current) {
28
+ let latest = /^(\d+)\.(\d+)\.(\d+)$/.exec(candidate);
29
+ let installed = /^(\d+)\.(\d+)\.(\d+)(-[\w.-]+)?(?:\+[\w.-]+)?$/.exec(current);
30
+ if (!latest || !installed) return false;
31
+ for (let index = 1; index <= 3; index++) {
32
+ let difference = Number(latest[index]) - Number(installed[index]);
33
+ if (difference) return difference > 0;
34
+ }
35
+ return Boolean(installed[4]);
36
+ }
37
+ export async function checkForUpdate() {
38
+ try {
39
+ let filename = path.join(getGlobalConfigDir(), 'updates.json');
40
+ let response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(getPackageName())}/latest`, {
41
+ signal: AbortSignal.timeout(3000)
42
+ });
43
+ if (!response.ok) return;
44
+ let {
45
+ version
46
+ } = await response.json();
47
+ if (!/^\d+\.\d+\.\d+$/.test(version)) return;
48
+ // Keep notice history saved while the network request was in flight.
49
+ let state = await readState(filename);
50
+ await applyChanges([await stateChange(filename, {
51
+ ...state,
52
+ latestVersion: version
53
+ })]);
54
+ } catch {
55
+ // Update checks never affect the command being run.
56
+ }
57
+ }
58
+ async function offerUpdate(cwd, {
59
+ cliVersion,
60
+ guidanceFlag
61
+ }) {
62
+ if (!process.stdin.isTTY) return;
63
+ let cliPlan = cliVersion ? await findCliUpdatePlan(cwd, cliVersion) : null;
64
+ if (!cliPlan && !guidanceFlag) return;
65
+ let updatedCli;
66
+ try {
67
+ let guidancePlan;
68
+ if (!cliPlan && guidanceFlag) {
69
+ guidancePlan = await planAgentSetup(cwd, {
70
+ skill: true,
71
+ guidance: guidanceFlag === '--agent-guidance'
72
+ });
73
+ await preflightChanges(guidancePlan.changes);
74
+ }
75
+ if (cliPlan) {
76
+ output.printErr(` Apply: ${cliPlan.command} ${cliPlan.args.join(' ')} (in ${cliPlan.cwd})`);
77
+ // A CLI upgrade can bring new guidance even if the current bundle matches.
78
+ if (!guidanceFlag && (await readOptionalFile(path.join(cwd, '.agents', 'skills', 'vizzly', 'SKILL.md')))) {
79
+ guidanceFlag = await agentGuidanceFlag(cwd);
80
+ }
81
+ }
82
+ if (guidanceFlag) {
83
+ output.printErr(` Refresh: vizzly init ${guidanceFlag} (replaces bundled skill files${guidanceFlag === '--agent-guidance' ? ' and the Vizzly AGENTS.md note' : ''})`);
84
+ }
85
+ if (!(await confirmUpdate())) return;
86
+ if (cliPlan) {
87
+ await runUpdateProcess(cliPlan.command, cliPlan.args, cliPlan.cwd);
88
+ updatedCli = cliPlan.cliPath;
89
+ if (guidanceFlag) {
90
+ await runUpdateProcess(process.execPath, [cliPlan.cliPath, 'init', guidanceFlag, '--no-update-notifier'], cwd);
91
+ }
92
+ } else {
93
+ await applyChanges(guidancePlan.changes);
94
+ }
95
+ output.printErr(' Vizzly update applied.');
96
+ } catch (error) {
97
+ if (error.code !== 'INIT_CANCELLED') output.printErr(` Could not apply the Vizzly update: ${error.message}`);
98
+ }
99
+ return updatedCli;
100
+ }
101
+ async function confirmUpdate() {
102
+ let prompts = createPrompts();
103
+ try {
104
+ return await prompts.confirm('Apply available Vizzly updates now?');
105
+ } finally {
106
+ prompts.close();
107
+ }
108
+ }
109
+ async function planGuidanceNotice(cwd, cliVersion) {
110
+ let installed = await readOptionalFile(path.join(cwd, '.agents', 'skills', 'vizzly', 'SKILL.md'));
111
+ let guidance = await readState(agentGuidancePath(cwd));
112
+ if (!installed || isNewerStableVersion(guidance.cliVersion, cliVersion)) return null;
113
+ let {
114
+ version,
115
+ files
116
+ } = await bundledAgentSkill();
117
+ if (!guidance.version) {
118
+ let contents = await Promise.all(files.map(({
119
+ filename
120
+ }) => readOptionalFile(path.join(cwd, '.agents', 'skills', 'vizzly', filename))));
121
+ if (files.every(({
122
+ content
123
+ }, index) => content === contents[index])) guidance.version = version;
124
+ }
125
+ if (guidance.version === version || guidance.notifiedVersion === version) return null;
126
+ let flag = await agentGuidanceFlag(cwd);
127
+ return {
128
+ flag,
129
+ message: `Vizzly agent guidance has an update. Run vizzly init ${flag} to refresh it.`,
130
+ change: await stateChange(agentGuidancePath(cwd), {
131
+ ...guidance,
132
+ notifiedVersion: version
133
+ })
134
+ };
135
+ }
136
+ function startUpdateCheck() {
137
+ output.printErr(' Checking for Vizzly updates in the background…');
138
+ let child = spawn(process.execPath, [fileURLToPath(new URL('./update-check.js', import.meta.url))], {
139
+ detached: true,
140
+ stdio: 'ignore'
141
+ });
142
+ child.on('error', () => {});
143
+ child.unref();
144
+ }
145
+ export async function notifyUpdates(options = {}) {
146
+ if (options.updateNotifier === false || options.json || output.isJson() || !process.stderr.isTTY || !process.stdout.isTTY || process.env.CI || 'NO_UPDATE_NOTIFIER' in process.env || 'VIZZLY_NO_UPDATE_NOTIFIER' in process.env || !['info', 'debug'].includes(output.getLogLevel())) return;
147
+ try {
148
+ let cwd = process.cwd();
149
+ let filename = path.join(getGlobalConfigDir(), 'updates.json');
150
+ let state = await readState(filename);
151
+ let messages = [];
152
+ let available = {};
153
+ let cliVersion = getPackageVersion();
154
+ if (isNewerStableVersion(state.latestVersion, cliVersion) && state.notifiedCliVersion !== state.latestVersion) {
155
+ messages.push(`Vizzly ${state.latestVersion} is available (installed: ${cliVersion}). Update ${getPackageName()} with the package manager that installed it.`);
156
+ state.notifiedCliVersion = state.latestVersion;
157
+ available.cliVersion = state.latestVersion;
158
+ }
159
+ let guidanceNotice = await planGuidanceNotice(cwd, cliVersion);
160
+ if (guidanceNotice) {
161
+ messages.push(guidanceNotice.message);
162
+ available.guidanceFlag = guidanceNotice.flag;
163
+ }
164
+ let checkDue = !Number.isFinite(state.lastCheckedAt) || state.lastCheckedAt > Date.now() || Date.now() - state.lastCheckedAt >= day;
165
+ if (checkDue) state.lastCheckedAt = Date.now();
166
+ // If state cannot be saved, stay quiet instead of repeating a notice on
167
+ // every invocation. Setup's file preflight also protects against symlinks.
168
+ await applyChanges([guidanceNotice?.change, await stateChange(filename, state)]);
169
+ for (let message of messages) output.printErr(` ${message}`);
170
+ if (checkDue) startUpdateCheck();
171
+ return await offerUpdate(cwd, available);
172
+ } catch {
173
+ // Missing, malformed, or unwritable metadata must not break CLI commands.
174
+ }
175
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vizzly-testing/cli",
3
- "version": "0.38.1",
3
+ "version": "0.39.1",
4
4
  "description": "Visual regression testing from your terminal",
5
5
  "keywords": [
6
6
  "llm-context",
@@ -99,12 +99,12 @@
99
99
  "ansis": "^4.4.0",
100
100
  "commander": "^15.0.0",
101
101
  "cosmiconfig": "^10.0.1",
102
- "dotenv": "^17.4.2",
102
+ "dotenv": "^18.0.9",
103
103
  "form-data": "^4.0.6",
104
104
  "glob": "^13.0.6",
105
- "react": "^19.3.0",
106
- "react-dom": "^19.3.0",
107
- "zod": "^4.6.5"
105
+ "react": "^19.3.1",
106
+ "react-dom": "^19.3.1",
107
+ "zod": "^4.6.7"
108
108
  },
109
109
  "tsd": {
110
110
  "directory": "test-d",
@@ -118,28 +118,28 @@
118
118
  },
119
119
  "devDependencies": {
120
120
  "@babel/cli": "^8.0.6",
121
- "@babel/core": "^8.0.6",
122
- "@babel/preset-env": "^8.0.6",
121
+ "@babel/core": "^8.0.7",
122
+ "@babel/preset-env": "^8.0.7",
123
123
  "@babel/preset-react": "^8.0.1",
124
- "@babel/preset-typescript": "^8.0.1",
125
- "@biomejs/biome": "^2.5.15",
124
+ "@babel/preset-typescript": "^8.0.7",
125
+ "@biomejs/biome": "^2.6.2",
126
126
  "@heroicons/react": "^2.2.0",
127
- "@modelcontextprotocol/sdk": "^1.31.0",
128
- "@playwright/test": "^1.63.0",
127
+ "@modelcontextprotocol/sdk": "^1.32.1",
128
+ "@playwright/test": "^1.64.1",
129
129
  "@tailwindcss/postcss": "^4.3.3",
130
- "@tanstack/react-query": "^5.104.0",
131
- "@types/node": "^26.6.3",
130
+ "@tanstack/react-query": "^5.104.5",
131
+ "@types/node": "^26.6.5",
132
132
  "@vitejs/plugin-react": "^6.1.2",
133
133
  "autoprefixer": "^10.6.1",
134
134
  "babel-plugin-react-compiler": "^1.0.0",
135
135
  "babel-plugin-transform-remove-console": "^6.9.4",
136
- "postcss": "^8.5.29",
136
+ "postcss": "^8.5.31",
137
137
  "rimraf": "^6.1.3",
138
138
  "tailwindcss": "^4.3.3",
139
139
  "tsd": "^0.33.0",
140
140
  "tui-driver": "github:vizzly-testing/tui-driver",
141
141
  "typescript": "^7.0.2",
142
- "vite": "^8.3.1",
143
- "wouter": "^3.11.1"
142
+ "vite": "^8.3.5",
143
+ "wouter": "^3.13.0"
144
144
  }
145
145
  }
@@ -41,8 +41,9 @@ output for guided inspection.
41
41
  pagination before drawing conclusions. Follow the schema's page instructions
42
42
  for cloud data. Missing fields remain unknown.
43
43
  4. For cloud data, discover the comparison and image operations from the schema.
44
- View the baseline, current, and diff images together. If an image cannot be
45
- opened, label the result metadata-only; do not call it visual verification.
44
+ View the current image with the baseline and diff when they're available.
45
+ A new screenshot may have no baseline or diff. Say which images you inspected;
46
+ if you couldn't open any images, label the result metadata-only.
46
47
  5. Read image dimensions, viewport, browser, diff regions, fingerprint, and
47
48
  relevant history alongside the images. A prior approval is supporting
48
49
  evidence, not permission to approve the current comparison.
@@ -69,4 +70,4 @@ output for guided inspection.
69
70
  - [SDK capture](references/sdks.md): add or change screenshot capture code.
70
71
  - [Dynamic content](references/dynamic-content.md): investigate unstable
71
72
  content and screenshot-specific tolerances.
72
- - [Setup and CI](references/setup-ci.md): initialize Vizzly or change CI.
73
+ - [CI](references/ci.md): run visual tests with committed config and CI secrets.
@@ -0,0 +1,33 @@
1
+ # CI
2
+
3
+ CI uses the config committed to the repository. Set up Vizzly locally first.
4
+
5
+ ## Cloud CI
6
+
7
+ Use a project token from the repo's existing secret store. Never print it or
8
+ save it in the repo.
9
+
10
+ ```bash
11
+ vizzly run "<existing visual test command>" --json
12
+ ```
13
+
14
+ Add `--wait` when you want CI to wait for cloud comparisons and fail on visual
15
+ differences. Changed comparisons then make the command exit with code `1`.
16
+
17
+ For parallel CI, give every shard the same ID for that CI run. Run shards
18
+ without `--wait`, then finalize once they all finish:
19
+
20
+ ```bash
21
+ vizzly run "<shard test command>" --parallel-id "<shared-ci-run-id>" --json
22
+ vizzly finalize "<shared-ci-run-id>" --json
23
+ ```
24
+
25
+ ## Troubleshoot
26
+
27
+ - `vizzly doctor`: check local config.
28
+ - `vizzly status <build-id> --json`: check cloud build status.
29
+ - `vizzly api schema --json`: find cloud review requests and their inputs
30
+ and outputs.
31
+
32
+ If screenshots are missing, check the integration and the test that should
33
+ capture them. If auth is missing, check the `VIZZLY_TOKEN` CI secret.