@vizzly-testing/cli 0.29.6 → 0.31.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 (87) hide show
  1. package/README.md +34 -6
  2. package/dist/api/client.js +6 -3
  3. package/dist/api/endpoints.js +59 -0
  4. package/dist/api/index.js +1 -1
  5. package/dist/auth/core.js +1 -1
  6. package/dist/auth/index.js +2 -1
  7. package/dist/cli.js +206 -51
  8. package/dist/client/index.js +21 -29
  9. package/dist/commands/api.js +145 -90
  10. package/dist/commands/baselines.js +78 -47
  11. package/dist/commands/builds.js +9 -8
  12. package/dist/commands/comparisons.js +7 -8
  13. package/dist/commands/config-cmd.js +15 -7
  14. package/dist/commands/context.js +870 -0
  15. package/dist/commands/doctor.js +116 -81
  16. package/dist/commands/init.js +202 -201
  17. package/dist/commands/login.js +134 -65
  18. package/dist/commands/logout.js +18 -7
  19. package/dist/commands/preview.js +29 -57
  20. package/dist/commands/project.js +120 -0
  21. package/dist/commands/projects.js +9 -2
  22. package/dist/commands/review.js +236 -202
  23. package/dist/commands/run.js +75 -72
  24. package/dist/commands/status.js +228 -150
  25. package/dist/commands/tdd-daemon-process.js +98 -0
  26. package/dist/commands/tdd-daemon.js +318 -212
  27. package/dist/commands/tdd.js +23 -8
  28. package/dist/commands/upload.js +10 -9
  29. package/dist/commands/whoami.js +64 -26
  30. package/dist/config/core.js +37 -36
  31. package/dist/context/local-workspace-provider.js +532 -0
  32. package/dist/context/provider-resolver.js +36 -0
  33. package/dist/index.js +5 -4
  34. package/dist/plugin-loader.js +55 -49
  35. package/dist/project/core.js +2 -2
  36. package/dist/project/operations.js +6 -3
  37. package/dist/reporter/reporter-bundle.css +2 -1
  38. package/dist/reporter/reporter-bundle.iife.js +119 -119
  39. package/dist/reporter-ssr/ssr-entry.js +605 -509
  40. package/dist/sdk/index.js +162 -107
  41. package/dist/server/handlers/tdd-handler.js +30 -54
  42. package/dist/server/routers/assets.js +4 -0
  43. package/dist/server/routers/baseline.js +195 -178
  44. package/dist/server/routers/events.js +226 -148
  45. package/dist/server/routers/projects.js +14 -5
  46. package/dist/server/routers/screenshot.js +1 -31
  47. package/dist/services/build-manager.js +19 -97
  48. package/dist/services/config-service.js +26 -34
  49. package/dist/services/index.js +2 -4
  50. package/dist/services/static-report-generator.js +11 -12
  51. package/dist/services/test-runner.js +21 -27
  52. package/dist/tdd/core/hotspot-coverage.js +1 -1
  53. package/dist/tdd/index.js +1 -1
  54. package/dist/tdd/metadata/region-metadata.js +0 -37
  55. package/dist/tdd/runtime-context.js +78 -0
  56. package/dist/tdd/server-registry.js +38 -17
  57. package/dist/tdd/services/baseline-manager.js +23 -0
  58. package/dist/tdd/services/hotspot-service.js +1 -14
  59. package/dist/tdd/tdd-service.js +82 -113
  60. package/dist/types/client.d.ts +36 -1
  61. package/dist/types/index.d.ts +114 -22
  62. package/dist/types/sdk.d.ts +47 -7
  63. package/dist/uploader/core.js +5 -4
  64. package/dist/uploader/index.js +79 -2
  65. package/dist/uploader/operations.js +40 -5
  66. package/dist/utils/api-url.js +14 -0
  67. package/dist/utils/async-utils.js +26 -0
  68. package/dist/utils/ci-env.js +19 -12
  69. package/dist/utils/colors.js +46 -44
  70. package/dist/utils/config-loader.js +39 -65
  71. package/dist/utils/config-schema.js +19 -55
  72. package/dist/utils/fetch-utils.js +12 -5
  73. package/dist/utils/git.js +35 -72
  74. package/dist/utils/global-config.js +22 -3
  75. package/dist/utils/output.js +19 -7
  76. package/dist/utils/patterns.js +14 -0
  77. package/dist/utils/project-link-store.js +105 -0
  78. package/dist/utils/report-data.js +31 -0
  79. package/dist/utils/screenshot-options.js +28 -0
  80. package/dist/utils/secret-store.js +65 -0
  81. package/dist/utils/security.js +3 -2
  82. package/package.json +14 -11
  83. package/dist/services/screenshot-server.js +0 -65
  84. package/dist/services/server-manager.js +0 -81
  85. package/dist/services/uploader.js +0 -100
  86. package/dist/tdd/services/region-service.js +0 -75
  87. package/dist/utils/build-history.js +0 -103
@@ -4,21 +4,22 @@
4
4
  */
5
5
 
6
6
  import { z } from 'zod';
7
+ import { CONFIG_DEFAULTS } from '../config/core.js';
7
8
 
8
9
  /**
9
10
  * Server configuration schema
10
11
  */
11
12
  const serverSchema = z.object({
12
- port: z.number().int().positive().default(47392),
13
- timeout: z.number().int().positive().default(30000)
13
+ port: z.number().int().positive().default(CONFIG_DEFAULTS.server.port),
14
+ timeout: z.number().int().positive().default(CONFIG_DEFAULTS.server.timeout)
14
15
  });
15
16
 
16
17
  /**
17
18
  * Build configuration schema
18
19
  */
19
20
  const buildSchema = z.object({
20
- name: z.string().default('Build {timestamp}'),
21
- environment: z.string().default('test'),
21
+ name: z.string().default(CONFIG_DEFAULTS.build.name),
22
+ environment: z.string().default(CONFIG_DEFAULTS.build.environment),
22
23
  branch: z.string().optional(),
23
24
  commit: z.string().optional(),
24
25
  message: z.string().optional()
@@ -28,9 +29,9 @@ const buildSchema = z.object({
28
29
  * Upload configuration schema
29
30
  */
30
31
  const uploadSchema = z.object({
31
- screenshotsDir: z.union([z.string(), z.array(z.string())]).default('./screenshots'),
32
- batchSize: z.number().int().positive().default(10),
33
- timeout: z.number().int().positive().default(30000)
32
+ screenshotsDir: z.union([z.string(), z.array(z.string())]).default(CONFIG_DEFAULTS.upload.screenshotsDir),
33
+ batchSize: z.number().int().positive().default(CONFIG_DEFAULTS.upload.batchSize),
34
+ timeout: z.number().int().positive().default(CONFIG_DEFAULTS.upload.timeout)
34
35
  });
35
36
 
36
37
  /**
@@ -39,15 +40,15 @@ const uploadSchema = z.object({
39
40
  * minClusterSize: pixels (1 = exact)
40
41
  */
41
42
  const comparisonSchema = z.object({
42
- threshold: z.number().min(0).default(2.0),
43
- minClusterSize: z.int().min(1).default(2)
43
+ threshold: z.number().min(0).default(CONFIG_DEFAULTS.comparison.threshold),
44
+ minClusterSize: z.int().min(1).default(CONFIG_DEFAULTS.comparison.minClusterSize)
44
45
  });
45
46
 
46
47
  /**
47
48
  * TDD configuration schema
48
49
  */
49
50
  const tddSchema = z.object({
50
- openReport: z.boolean().default(false)
51
+ openReport: z.boolean().default(CONFIG_DEFAULTS.tdd.openReport)
51
52
  });
52
53
 
53
54
  /**
@@ -57,29 +58,14 @@ const tddSchema = z.object({
57
58
  export const vizzlyConfigSchema = z.object({
58
59
  // Core Vizzly config
59
60
  apiKey: z.string().optional(),
60
- apiUrl: z.string().url().optional(),
61
- server: serverSchema.default({
62
- port: 47392,
63
- timeout: 30000
64
- }),
65
- build: buildSchema.default({
66
- name: 'Build {timestamp}',
67
- environment: 'test'
68
- }),
69
- upload: uploadSchema.default({
70
- screenshotsDir: './screenshots',
71
- batchSize: 10,
72
- timeout: 30000
73
- }),
74
- comparison: comparisonSchema.default({
75
- threshold: 2.0,
76
- minClusterSize: 2
77
- }),
78
- tdd: tddSchema.default({
79
- openReport: false
80
- }),
61
+ apiUrl: z.string().url().default(CONFIG_DEFAULTS.apiUrl),
62
+ server: serverSchema.default(CONFIG_DEFAULTS.server),
63
+ build: buildSchema.default(CONFIG_DEFAULTS.build),
64
+ upload: uploadSchema.default(CONFIG_DEFAULTS.upload),
65
+ comparison: comparisonSchema.default(CONFIG_DEFAULTS.comparison),
66
+ tdd: tddSchema.default(CONFIG_DEFAULTS.tdd),
81
67
  signatureProperties: z.array(z.string()).default([]),
82
- plugins: z.array(z.string()).default([]),
68
+ plugins: z.array(z.string()).default(CONFIG_DEFAULTS.plugins),
83
69
  // Additional optional fields
84
70
  parallelId: z.string().optional(),
85
71
  baselineBuildId: z.string().optional(),
@@ -88,29 +74,7 @@ export const vizzlyConfigSchema = z.object({
88
74
  wait: z.boolean().optional(),
89
75
  allowNoToken: z.boolean().optional()
90
76
  }).passthrough() // Allow plugin-specific keys like `staticSite`, `storybook`, etc.
91
- .default({
92
- server: {
93
- port: 47392,
94
- timeout: 30000
95
- },
96
- build: {
97
- name: 'Build {timestamp}',
98
- environment: 'test'
99
- },
100
- upload: {
101
- screenshotsDir: './screenshots',
102
- batchSize: 10,
103
- timeout: 30000
104
- },
105
- comparison: {
106
- threshold: 2.0,
107
- minClusterSize: 2
108
- },
109
- tdd: {
110
- openReport: false
111
- },
112
- plugins: []
113
- });
77
+ .default(CONFIG_DEFAULTS);
114
78
 
115
79
  /**
116
80
  * Validate Vizzly configuration
@@ -1,9 +1,16 @@
1
- function fetchWithTimeout(url, opts = {}, ms = 300000) {
2
- const ctrl = new AbortController();
3
- const id = setTimeout(() => ctrl.abort(), ms);
4
- return fetch(url, {
1
+ let defaultTimers = {
2
+ setTimeout,
3
+ clearTimeout
4
+ };
5
+ function fetchWithTimeout(url, opts = {}, ms = 300000, deps = {}) {
6
+ let fetchFn = deps.fetch || fetch;
7
+ let AbortControllerClass = deps.AbortController || AbortController;
8
+ let timers = deps.timers || defaultTimers;
9
+ let ctrl = new AbortControllerClass();
10
+ let timeoutId = timers.setTimeout(() => ctrl.abort(), ms);
11
+ return fetchFn(url, {
5
12
  ...opts,
6
13
  signal: ctrl.signal
7
- }).finally(() => clearTimeout(id));
14
+ }).finally(() => timers.clearTimeout(timeoutId));
8
15
  }
9
16
  export { fetchWithTimeout };
package/dist/utils/git.js CHANGED
@@ -1,15 +1,18 @@
1
- import { exec } from 'node:child_process';
1
+ import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import { getBranch as getCIBranch, getCommit as getCICommit, getCommitMessage as getCICommitMessage, getPullRequestNumber } from './ci-env.js';
4
- const execAsync = promisify(exec);
4
+ let execFileAsync = promisify(execFile);
5
+ async function runGit(args, cwd = process.cwd()) {
6
+ let {
7
+ stdout
8
+ } = await execFileAsync('git', args, {
9
+ cwd
10
+ });
11
+ return stdout.trim();
12
+ }
5
13
  export async function getCommonAncestor(commit1, commit2, cwd = process.cwd()) {
6
14
  try {
7
- const {
8
- stdout
9
- } = await execAsync(`git merge-base ${commit1} ${commit2}`, {
10
- cwd
11
- });
12
- return stdout.trim();
15
+ return await runGit(['merge-base', commit1, commit2], cwd);
13
16
  } catch {
14
17
  // If merge-base fails (e.g., no common ancestor), return null
15
18
  return null;
@@ -17,24 +20,14 @@ export async function getCommonAncestor(commit1, commit2, cwd = process.cwd()) {
17
20
  }
18
21
  export async function getCurrentCommitSha(cwd = process.cwd()) {
19
22
  try {
20
- const {
21
- stdout
22
- } = await execAsync('git rev-parse HEAD', {
23
- cwd
24
- });
25
- return stdout.trim();
23
+ return await runGit(['rev-parse', 'HEAD'], cwd);
26
24
  } catch {
27
25
  return null;
28
26
  }
29
27
  }
30
28
  export async function getCurrentBranch(cwd = process.cwd()) {
31
29
  try {
32
- const {
33
- stdout
34
- } = await execAsync('git rev-parse --abbrev-ref HEAD', {
35
- cwd
36
- });
37
- return stdout.trim();
30
+ return await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
38
31
  } catch {
39
32
  // Fallback strategy: use a simple, non-recursive approach
40
33
  // to avoid circular dependency with getDefaultBranch()
@@ -48,24 +41,18 @@ export async function getCurrentBranch(cwd = process.cwd()) {
48
41
  */
49
42
  async function getCurrentBranchFallback(cwd = process.cwd()) {
50
43
  // Try common default branches in order of likelihood
51
- const commonBranches = ['main', 'master', 'develop', 'dev'];
52
- for (const branch of commonBranches) {
44
+ let commonBranches = ['main', 'master', 'develop', 'dev'];
45
+ for (let branch of commonBranches) {
53
46
  try {
54
- await execAsync(`git show-ref --verify --quiet refs/heads/${branch}`, {
55
- cwd
56
- });
47
+ await runGit(['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], cwd);
57
48
  return branch;
58
49
  } catch {}
59
50
  }
60
51
 
61
52
  // If none of the common branches exist, try to get any local branch
62
53
  try {
63
- const {
64
- stdout
65
- } = await execAsync('git branch --format="%(refname:short)"', {
66
- cwd
67
- });
68
- const branches = stdout.trim().split('\n').filter(b => b.trim());
54
+ let stdout = await runGit(['branch', '--format=%(refname:short)'], cwd);
55
+ let branches = stdout.trim().split('\n').filter(b => b.trim());
69
56
  if (branches.length > 0) {
70
57
  return branches[0]; // Return the first available branch
71
58
  }
@@ -80,35 +67,22 @@ async function getCurrentBranchFallback(cwd = process.cwd()) {
80
67
  export async function getDefaultBranch(cwd = process.cwd()) {
81
68
  try {
82
69
  // Try to get the default branch from remote origin
83
- const {
84
- stdout
85
- } = await execAsync('git symbolic-ref refs/remotes/origin/HEAD', {
86
- cwd
87
- });
88
- const defaultBranch = stdout.trim().replace('refs/remotes/origin/', '');
70
+ let stdout = await runGit(['symbolic-ref', 'refs/remotes/origin/HEAD'], cwd);
71
+ let defaultBranch = stdout.replace('refs/remotes/origin/', '');
89
72
  return defaultBranch;
90
73
  } catch {
91
74
  try {
92
75
  // Fallback: try to get default branch from git config
93
- const {
94
- stdout
95
- } = await execAsync('git config --get init.defaultBranch', {
96
- cwd
97
- });
98
- return stdout.trim();
76
+ return await runGit(['config', '--get', 'init.defaultBranch'], cwd);
99
77
  } catch {
100
78
  try {
101
79
  // Fallback: check if main exists
102
- await execAsync('git show-ref --verify --quiet refs/heads/main', {
103
- cwd
104
- });
80
+ await runGit(['show-ref', '--verify', '--quiet', 'refs/heads/main'], cwd);
105
81
  return 'main';
106
82
  } catch {
107
83
  try {
108
84
  // Fallback: check if master exists
109
- await execAsync('git show-ref --verify --quiet refs/heads/master', {
110
- cwd
111
- });
85
+ await runGit(['show-ref', '--verify', '--quiet', 'refs/heads/master'], cwd);
112
86
  return 'master';
113
87
  } catch {
114
88
  // If we're not in a git repo or no branches exist, return null
@@ -120,7 +94,7 @@ export async function getDefaultBranch(cwd = process.cwd()) {
120
94
  }
121
95
  }
122
96
  export function generateBuildName() {
123
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
97
+ let timestamp = new Date().toISOString().replace(/[:.]/g, '-');
124
98
  return `Build ${timestamp}`;
125
99
  }
126
100
 
@@ -131,12 +105,7 @@ export function generateBuildName() {
131
105
  */
132
106
  export async function getCommitMessage(cwd = process.cwd()) {
133
107
  try {
134
- const {
135
- stdout
136
- } = await execAsync('git log -1 --pretty=%B', {
137
- cwd
138
- });
139
- return stdout.trim();
108
+ return await runGit(['log', '-1', '--pretty=%B'], cwd);
140
109
  } catch {
141
110
  return null;
142
111
  }
@@ -152,7 +121,7 @@ export async function detectCommitMessage(override = null, cwd = process.cwd())
152
121
  if (override) return override;
153
122
 
154
123
  // Try CI environment variables first
155
- const ciCommitMessage = getCICommitMessage();
124
+ let ciCommitMessage = getCICommitMessage();
156
125
  if (ciCommitMessage) return ciCommitMessage;
157
126
 
158
127
  // Fallback to regular git log
@@ -166,9 +135,7 @@ export async function detectCommitMessage(override = null, cwd = process.cwd())
166
135
  */
167
136
  export async function isGitRepository(cwd = process.cwd()) {
168
137
  try {
169
- await execAsync('git rev-parse --git-dir', {
170
- cwd
171
- });
138
+ await runGit(['rev-parse', '--git-dir'], cwd);
172
139
  return true;
173
140
  } catch {
174
141
  return false;
@@ -182,12 +149,8 @@ export async function isGitRepository(cwd = process.cwd()) {
182
149
  */
183
150
  export async function getGitStatus(cwd = process.cwd()) {
184
151
  try {
185
- const {
186
- stdout
187
- } = await execAsync('git status --porcelain', {
188
- cwd
189
- });
190
- const changes = stdout.trim().split('\n').filter(line => line);
152
+ let stdout = await runGit(['status', '--porcelain'], cwd);
153
+ let changes = stdout.trim().split('\n').filter(line => line);
191
154
  return {
192
155
  hasChanges: changes.length > 0,
193
156
  changes: changes.map(line => ({
@@ -210,11 +173,11 @@ export async function detectBranch(override = null, cwd = process.cwd()) {
210
173
  if (override) return override;
211
174
 
212
175
  // Try CI environment variables first
213
- const ciBranch = getCIBranch();
176
+ let ciBranch = getCIBranch();
214
177
  if (ciBranch) return ciBranch;
215
178
 
216
179
  // Fallback to git command when no CI environment variables
217
- const currentBranch = await getCurrentBranch(cwd);
180
+ let currentBranch = await getCurrentBranch(cwd);
218
181
  return currentBranch || 'unknown';
219
182
  }
220
183
 
@@ -228,7 +191,7 @@ export async function detectCommit(override = null, cwd = process.cwd()) {
228
191
  if (override) return override;
229
192
 
230
193
  // Try CI environment variables first
231
- const ciCommit = getCICommit();
194
+ let ciCommit = getCICommit();
232
195
  if (ciCommit) return ciCommit;
233
196
 
234
197
  // Fallback to git command when no CI environment variables
@@ -243,10 +206,10 @@ export async function detectCommit(override = null, cwd = process.cwd()) {
243
206
  */
244
207
  export async function generateBuildNameWithGit(override = null, cwd = process.cwd()) {
245
208
  if (override) return override;
246
- const branch = await getCurrentBranch(cwd);
247
- const shortSha = await getCurrentCommitSha(cwd);
209
+ let branch = await getCurrentBranch(cwd);
210
+ let shortSha = await getCurrentCommitSha(cwd);
248
211
  if (branch && shortSha) {
249
- const shortCommit = shortSha.substring(0, 7);
212
+ let shortCommit = shortSha.substring(0, 7);
250
213
  return `${branch}-${shortCommit}`;
251
214
  }
252
215
  return generateBuildName();
@@ -8,6 +8,25 @@ import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
8
8
  import { homedir } from 'node:os';
9
9
  import { join } from 'node:path';
10
10
  import * as output from './output.js';
11
+ export function expandHomePath(path) {
12
+ if (!path) {
13
+ return path;
14
+ }
15
+ let home = homedir();
16
+ if (path === '$HOME') {
17
+ return home;
18
+ }
19
+ if (path.startsWith('$HOME/')) {
20
+ return join(home, path.slice('$HOME/'.length));
21
+ }
22
+ if (path === '~') {
23
+ return home;
24
+ }
25
+ if (path.startsWith('~/')) {
26
+ return join(home, path.slice(2));
27
+ }
28
+ return path;
29
+ }
11
30
 
12
31
  /**
13
32
  * Get the path to the global Vizzly directory
@@ -15,7 +34,7 @@ import * as output from './output.js';
15
34
  */
16
35
  export function getGlobalConfigDir() {
17
36
  if (process.env.VIZZLY_HOME) {
18
- return process.env.VIZZLY_HOME;
37
+ return expandHomePath(process.env.VIZZLY_HOME);
19
38
  }
20
39
  return join(homedir(), '.vizzly');
21
40
  }
@@ -132,7 +151,7 @@ export async function getUserPath() {
132
151
  */
133
152
  export async function getAuthTokens() {
134
153
  const config = await loadGlobalConfig();
135
- if (!config.auth || !config.auth.accessToken) {
154
+ if (!config.auth?.accessToken) {
136
155
  return null;
137
156
  }
138
157
  return config.auth;
@@ -170,7 +189,7 @@ export async function clearAuthTokens() {
170
189
  */
171
190
  export async function hasValidTokens() {
172
191
  const auth = await getAuthTokens();
173
- if (!auth || !auth.accessToken) {
192
+ if (!auth?.accessToken) {
174
193
  return false;
175
194
  }
176
195
 
@@ -603,15 +603,27 @@ function getComponentColor(component) {
603
603
  };
604
604
  return componentColors[component] || colors.brand.info;
605
605
  }
606
- export function debug(component, message, data = {}) {
607
- if (!shouldLog('debug')) return;
608
-
609
- // Handle legacy calls: debug('message') or debug('message', {data})
606
+ export function normalizeDebugArgs(component, message, data = {}) {
610
607
  if (typeof message === 'object' || message === undefined) {
611
- data = message || {};
612
- message = component;
613
- component = null;
608
+ return {
609
+ component: null,
610
+ message: component,
611
+ data: message || {}
612
+ };
614
613
  }
614
+ return {
615
+ component,
616
+ message,
617
+ data
618
+ };
619
+ }
620
+ export function debug(component, message, data = {}) {
621
+ if (!shouldLog('debug')) return;
622
+ ({
623
+ component,
624
+ message,
625
+ data
626
+ } = normalizeDebugArgs(component, message, data));
615
627
  let elapsed = getElapsedTime();
616
628
  if (config.json) {
617
629
  console.error(JSON.stringify({
@@ -0,0 +1,14 @@
1
+ export function escapeRegExp(value) {
2
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3
+ }
4
+ export function createWildcardMatcher(pattern, options = {}) {
5
+ let {
6
+ anchored = false
7
+ } = options;
8
+ let regexPattern = String(pattern).split('*').map(escapeRegExp).join('.*');
9
+ if (anchored) {
10
+ regexPattern = `^${regexPattern}$`;
11
+ }
12
+ let regex = new RegExp(regexPattern, 'i');
13
+ return value => regex.test(String(value));
14
+ }
@@ -0,0 +1,105 @@
1
+ import { loadGlobalConfig, saveGlobalConfig } from './global-config.js';
2
+ import { deleteSecret as defaultDeleteSecret, getSecret as defaultGetSecret, saveSecret as defaultSaveSecret } from './secret-store.js';
3
+ export function buildProjectLinkAccount({
4
+ apiUrl,
5
+ organizationSlug,
6
+ projectSlug
7
+ }) {
8
+ return `${apiUrl || 'https://app.vizzly.dev'}|${organizationSlug}/${projectSlug}`;
9
+ }
10
+ function getProjectLinkConfig(config) {
11
+ return config.projectLink || {
12
+ active: null,
13
+ links: {}
14
+ };
15
+ }
16
+ export async function saveProjectLink(link, deps = {}) {
17
+ let {
18
+ loadConfig = loadGlobalConfig,
19
+ saveConfig = saveGlobalConfig,
20
+ saveSecret = defaultSaveSecret,
21
+ now = () => new Date()
22
+ } = deps;
23
+ let config = await loadConfig();
24
+ let projectLink = getProjectLinkConfig(config);
25
+ let account = buildProjectLinkAccount(link);
26
+ let storedInKeychain = await saveSecret(account, link.token);
27
+ projectLink.active = account;
28
+ projectLink.links = {
29
+ ...projectLink.links,
30
+ [account]: {
31
+ apiUrl: link.apiUrl,
32
+ organizationSlug: link.organizationSlug,
33
+ organizationName: link.organizationName,
34
+ projectSlug: link.projectSlug,
35
+ projectName: link.projectName,
36
+ tokenId: link.tokenId,
37
+ tokenPrefix: link.tokenPrefix,
38
+ expiresAt: link.expiresAt || null,
39
+ createdAt: link.createdAt || now().toISOString(),
40
+ storage: storedInKeychain ? 'keychain' : 'file',
41
+ token: storedInKeychain ? undefined : link.token
42
+ }
43
+ };
44
+ config.projectLink = projectLink;
45
+ await saveConfig(config);
46
+ return {
47
+ ...projectLink.links[account],
48
+ account,
49
+ token: link.token
50
+ };
51
+ }
52
+ export async function getActiveProjectLink(options = {}, deps = {}) {
53
+ let {
54
+ loadConfig = loadGlobalConfig,
55
+ getSecret = defaultGetSecret
56
+ } = deps;
57
+ let config = await loadConfig();
58
+ let projectLink = getProjectLinkConfig(config);
59
+ let account = options.account || projectLink.active;
60
+ 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
+ if (!link) {
66
+ return null;
67
+ }
68
+ if (options.apiUrl && link.apiUrl && link.apiUrl !== options.apiUrl) {
69
+ return null;
70
+ }
71
+ let token = link.storage === 'keychain' ? await getSecret(account) : link.token;
72
+ if (!token) {
73
+ return null;
74
+ }
75
+ return {
76
+ ...link,
77
+ account,
78
+ token
79
+ };
80
+ }
81
+ export async function clearActiveProjectLink(deps = {}) {
82
+ let {
83
+ loadConfig = loadGlobalConfig,
84
+ saveConfig = saveGlobalConfig,
85
+ deleteSecret = defaultDeleteSecret
86
+ } = deps;
87
+ let config = await loadConfig();
88
+ let projectLink = getProjectLinkConfig(config);
89
+ let account = projectLink.active;
90
+ if (!account || !projectLink.links?.[account]) {
91
+ return null;
92
+ }
93
+ let link = projectLink.links[account];
94
+ if (link.storage === 'keychain') {
95
+ await deleteSecret(account);
96
+ }
97
+ delete projectLink.links[account];
98
+ projectLink.active = null;
99
+ config.projectLink = projectLink;
100
+ await saveConfig(config);
101
+ return {
102
+ ...link,
103
+ account
104
+ };
105
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Ensure each comparison has a timestamp for image cache-busting.
3
+ */
4
+ export function normalizeReportData(reportData) {
5
+ if (!reportData || !Array.isArray(reportData.comparisons)) {
6
+ return reportData;
7
+ }
8
+ let needsNormalization = reportData.comparisons.some(comparison => comparison && comparison.timestamp == null);
9
+ if (!needsNormalization) {
10
+ return reportData;
11
+ }
12
+ let fallbackTimestamp = reportData.timestamp ?? Date.now();
13
+ let comparisons = reportData.comparisons.map(comparison => normalizeComparisonUpdate(comparison, fallbackTimestamp));
14
+ return {
15
+ ...reportData,
16
+ comparisons
17
+ };
18
+ }
19
+
20
+ /**
21
+ * Ensure a single comparison includes a timestamp.
22
+ */
23
+ export function normalizeComparisonUpdate(comparison, fallbackTimestamp) {
24
+ if (!comparison || comparison.timestamp != null) {
25
+ return comparison;
26
+ }
27
+ return {
28
+ ...comparison,
29
+ timestamp: fallbackTimestamp ?? Date.now()
30
+ };
31
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Normalize screenshot SDK options into the properties payload consumed by the
3
+ * local TDD server and cloud-compatible comparison path.
4
+ */
5
+ export function createScreenshotProperties(options = {}) {
6
+ let {
7
+ buildId: _buildId,
8
+ properties = {},
9
+ threshold,
10
+ minClusterSize,
11
+ fullPage,
12
+ ...topLevelProperties
13
+ } = options;
14
+ let normalizedProperties = {
15
+ ...topLevelProperties,
16
+ ...properties
17
+ };
18
+ if (threshold !== undefined) {
19
+ normalizedProperties.threshold = threshold;
20
+ }
21
+ if (minClusterSize !== undefined) {
22
+ normalizedProperties.minClusterSize = minClusterSize;
23
+ }
24
+ if (fullPage !== undefined) {
25
+ normalizedProperties.fullPage = fullPage;
26
+ }
27
+ return normalizedProperties;
28
+ }