@vizzly-testing/cli 0.38.0 → 0.39.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 +222 -183
- package/dist/auth/client.js +4 -1
- package/dist/auth/core.js +64 -3
- package/dist/auth/operations.js +21 -11
- package/dist/cli.js +45 -53
- package/dist/commands/init.js +156 -483
- package/dist/commands/login.js +71 -137
- package/dist/commands/project.js +128 -44
- package/dist/commands/run.js +2 -2
- package/dist/commands/tdd.js +89 -38
- package/dist/commands/upload.js +2 -2
- package/dist/init/agents.js +99 -0
- package/dist/init/core.js +31 -0
- package/dist/init/prompts.js +44 -0
- package/dist/reporter/reporter-bundle.css +1 -1
- package/dist/reporter/reporter-bundle.iife.js +31 -127
- package/dist/server/handlers/tdd-handler.js +33 -2
- package/dist/server/routers/auth.js +32 -42
- package/dist/services/index.js +8 -2
- package/dist/tdd/services/comparison-service.js +4 -1
- package/dist/tdd/tdd-service.js +14 -2
- package/dist/test-runner/core.js +28 -3
- package/dist/test-runner/operations.js +41 -20
- package/dist/types/index.d.ts +8 -0
- package/dist/uploader/core.js +1 -0
- package/dist/utils/browser.js +7 -1
- package/dist/utils/config-discovery.js +73 -0
- package/dist/utils/config-loader.js +29 -8
- package/dist/utils/config-schema.js +5 -0
- package/dist/utils/file-changes.js +105 -0
- package/dist/utils/login-poll-wait.js +50 -0
- package/dist/utils/project-binding.js +96 -0
- package/dist/utils/project-link-store.js +13 -7
- package/package.json +27 -27
- package/skills/vizzly/SKILL.md +4 -3
- package/skills/vizzly/references/ci.md +33 -0
- package/skills/vizzly/references/cli-context.md +81 -71
- package/skills/vizzly/references/dynamic-content.md +16 -17
- package/skills/vizzly/references/sdks.md +22 -22
- 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,50 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
|
+
|
|
3
|
+
/** Wait for the next check, with optional terminal input and a live countdown. */
|
|
4
|
+
export async function waitForLoginPoll({
|
|
5
|
+
milliseconds,
|
|
6
|
+
manualAllowed = true,
|
|
7
|
+
earliestCheckAt = 0,
|
|
8
|
+
stdin = process.stdin,
|
|
9
|
+
now = Date.now,
|
|
10
|
+
sleep = delay,
|
|
11
|
+
onTick
|
|
12
|
+
}) {
|
|
13
|
+
if (!stdin.isTTY) {
|
|
14
|
+
onTick(milliseconds, false);
|
|
15
|
+
await sleep(milliseconds);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
let deadline = now() + milliseconds;
|
|
19
|
+
let requested = false;
|
|
20
|
+
let timer;
|
|
21
|
+
let wasFlowing = stdin.readableFlowing === true;
|
|
22
|
+
let onData = data => {
|
|
23
|
+
if (manualAllowed && /[\r\n]/.test(data.toString())) {
|
|
24
|
+
requested = true;
|
|
25
|
+
timer?.abort();
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
if (manualAllowed) {
|
|
29
|
+
stdin.on('data', onData);
|
|
30
|
+
stdin.resume();
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
while (true) {
|
|
34
|
+
let remaining = Math.min(deadline, requested ? earliestCheckAt : deadline) - now();
|
|
35
|
+
if (remaining <= 0) return;
|
|
36
|
+
onTick(remaining, manualAllowed && !requested);
|
|
37
|
+
timer = new AbortController();
|
|
38
|
+
try {
|
|
39
|
+
await sleep(Math.min(remaining, 1000), undefined, {
|
|
40
|
+
signal: timer.signal
|
|
41
|
+
});
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (!timer.signal.aborted) throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
} finally {
|
|
47
|
+
stdin.removeListener('data', onData);
|
|
48
|
+
if (manualAllowed && !wasFlowing) stdin.pause();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -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
|
|
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 ||
|
|
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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vizzly-testing/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.0",
|
|
4
4
|
"description": "Visual regression testing from your terminal",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"llm-context",
|
|
@@ -87,24 +87,24 @@
|
|
|
87
87
|
"fix": "pnpm run format && pnpm run lint:fix"
|
|
88
88
|
},
|
|
89
89
|
"engines": {
|
|
90
|
-
"node": "
|
|
90
|
+
"node": "^22.18.0 || >=24.0.0"
|
|
91
91
|
},
|
|
92
92
|
"publishConfig": {
|
|
93
93
|
"access": "public",
|
|
94
94
|
"registry": "https://registry.npmjs.org/"
|
|
95
95
|
},
|
|
96
96
|
"dependencies": {
|
|
97
|
-
"@vizzly-testing/bear-den": "0.1.
|
|
97
|
+
"@vizzly-testing/bear-den": "0.1.6",
|
|
98
98
|
"@vizzly-testing/honeydiff": "^0.17.0",
|
|
99
|
-
"ansis": "^4.
|
|
99
|
+
"ansis": "^4.4.0",
|
|
100
100
|
"commander": "^15.0.0",
|
|
101
|
-
"cosmiconfig": "^
|
|
102
|
-
"dotenv": "^
|
|
103
|
-
"form-data": "^4.0.
|
|
104
|
-
"glob": "^13.0.
|
|
105
|
-
"react": "^19.
|
|
106
|
-
"react-dom": "^19.
|
|
107
|
-
"zod": "^4.
|
|
101
|
+
"cosmiconfig": "^10.0.1",
|
|
102
|
+
"dotenv": "^18.0.3",
|
|
103
|
+
"form-data": "^4.0.6",
|
|
104
|
+
"glob": "^13.0.6",
|
|
105
|
+
"react": "^19.3.0",
|
|
106
|
+
"react-dom": "^19.3.0",
|
|
107
|
+
"zod": "^4.6.7"
|
|
108
108
|
},
|
|
109
109
|
"tsd": {
|
|
110
110
|
"directory": "test-d",
|
|
@@ -117,29 +117,29 @@
|
|
|
117
117
|
}
|
|
118
118
|
},
|
|
119
119
|
"devDependencies": {
|
|
120
|
-
"@babel/cli": "^8.0.
|
|
121
|
-
"@babel/core": "^8.0.
|
|
122
|
-
"@babel/preset-env": "^8.0.
|
|
120
|
+
"@babel/cli": "^8.0.6",
|
|
121
|
+
"@babel/core": "^8.0.6",
|
|
122
|
+
"@babel/preset-env": "^8.0.6",
|
|
123
123
|
"@babel/preset-react": "^8.0.1",
|
|
124
124
|
"@babel/preset-typescript": "^8.0.1",
|
|
125
|
-
"@biomejs/biome": "^2.5.
|
|
125
|
+
"@biomejs/biome": "^2.5.15",
|
|
126
126
|
"@heroicons/react": "^2.2.0",
|
|
127
|
-
"@modelcontextprotocol/sdk": "^1.0
|
|
128
|
-
"@playwright/test": "^1.
|
|
129
|
-
"@tailwindcss/postcss": "^4.
|
|
130
|
-
"@tanstack/react-query": "^5.
|
|
131
|
-
"@types/node": "^26.
|
|
132
|
-
"@vitejs/plugin-react": "^6.
|
|
133
|
-
"autoprefixer": "^10.
|
|
127
|
+
"@modelcontextprotocol/sdk": "^1.31.0",
|
|
128
|
+
"@playwright/test": "^1.63.0",
|
|
129
|
+
"@tailwindcss/postcss": "^4.3.3",
|
|
130
|
+
"@tanstack/react-query": "^5.104.0",
|
|
131
|
+
"@types/node": "^26.6.3",
|
|
132
|
+
"@vitejs/plugin-react": "^6.1.2",
|
|
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.
|
|
137
|
-
"rimraf": "^6.
|
|
138
|
-
"tailwindcss": "^4.
|
|
136
|
+
"postcss": "^8.5.29",
|
|
137
|
+
"rimraf": "^6.1.3",
|
|
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.
|
|
143
|
-
"wouter": "^3.
|
|
142
|
+
"vite": "^8.3.1",
|
|
143
|
+
"wouter": "^3.11.1"
|
|
144
144
|
}
|
|
145
145
|
}
|
package/skills/vizzly/SKILL.md
CHANGED
|
@@ -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
|
|
45
|
-
|
|
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
|
-
- [
|
|
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.
|