@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.
Files changed (40) hide show
  1. package/README.md +222 -183
  2. package/dist/auth/client.js +4 -1
  3. package/dist/auth/core.js +64 -3
  4. package/dist/auth/operations.js +21 -11
  5. package/dist/cli.js +45 -53
  6. package/dist/commands/init.js +156 -483
  7. package/dist/commands/login.js +71 -137
  8. package/dist/commands/project.js +128 -44
  9. package/dist/commands/run.js +2 -2
  10. package/dist/commands/tdd.js +89 -38
  11. package/dist/commands/upload.js +2 -2
  12. package/dist/init/agents.js +99 -0
  13. package/dist/init/core.js +31 -0
  14. package/dist/init/prompts.js +44 -0
  15. package/dist/reporter/reporter-bundle.css +1 -1
  16. package/dist/reporter/reporter-bundle.iife.js +31 -127
  17. package/dist/server/handlers/tdd-handler.js +33 -2
  18. package/dist/server/routers/auth.js +32 -42
  19. package/dist/services/index.js +8 -2
  20. package/dist/tdd/services/comparison-service.js +4 -1
  21. package/dist/tdd/tdd-service.js +14 -2
  22. package/dist/test-runner/core.js +28 -3
  23. package/dist/test-runner/operations.js +41 -20
  24. package/dist/types/index.d.ts +8 -0
  25. package/dist/uploader/core.js +1 -0
  26. package/dist/utils/browser.js +7 -1
  27. package/dist/utils/config-discovery.js +73 -0
  28. package/dist/utils/config-loader.js +29 -8
  29. package/dist/utils/config-schema.js +5 -0
  30. package/dist/utils/file-changes.js +105 -0
  31. package/dist/utils/login-poll-wait.js +50 -0
  32. package/dist/utils/project-binding.js +96 -0
  33. package/dist/utils/project-link-store.js +13 -7
  34. package/package.json +27 -27
  35. package/skills/vizzly/SKILL.md +4 -3
  36. package/skills/vizzly/references/ci.md +33 -0
  37. package/skills/vizzly/references/cli-context.md +81 -71
  38. package/skills/vizzly/references/dynamic-content.md +16 -17
  39. package/skills/vizzly/references/sdks.md +22 -22
  40. 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 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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vizzly-testing/cli",
3
- "version": "0.38.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": ">=22.0.0"
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.4",
97
+ "@vizzly-testing/bear-den": "0.1.6",
98
98
  "@vizzly-testing/honeydiff": "^0.17.0",
99
- "ansis": "^4.2.0",
99
+ "ansis": "^4.4.0",
100
100
  "commander": "^15.0.0",
101
- "cosmiconfig": "^9.0.0",
102
- "dotenv": "^17.2.1",
103
- "form-data": "^4.0.0",
104
- "glob": "^13.0.0",
105
- "react": "^19.1.1",
106
- "react-dom": "^19.1.1",
107
- "zod": "^4.1.12"
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.1",
121
- "@babel/core": "^8.0.1",
122
- "@babel/preset-env": "^8.0.2",
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.10",
125
+ "@biomejs/biome": "^2.5.15",
126
126
  "@heroicons/react": "^2.2.0",
127
- "@modelcontextprotocol/sdk": "^1.0.4",
128
- "@playwright/test": "^1.55.1",
129
- "@tailwindcss/postcss": "^4.1.13",
130
- "@tanstack/react-query": "^5.90.11",
131
- "@types/node": "^26.0.0",
132
- "@vitejs/plugin-react": "^6.0.1",
133
- "autoprefixer": "^10.4.21",
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.6",
137
- "rimraf": "^6.0.1",
138
- "tailwindcss": "^4.1.13",
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.2.2",
143
- "wouter": "^3.7.1"
142
+ "vite": "^8.3.1",
143
+ "wouter": "^3.11.1"
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.