@vizzly-testing/cli 0.30.0 → 0.32.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 (79) hide show
  1. package/README.md +9 -9
  2. package/dist/api/client.js +6 -3
  3. package/dist/cli.js +58 -29
  4. package/dist/client/index.js +21 -29
  5. package/dist/commands/api.js +145 -90
  6. package/dist/commands/baselines.js +78 -47
  7. package/dist/commands/builds.js +9 -8
  8. package/dist/commands/comparisons.js +7 -8
  9. package/dist/commands/config-cmd.js +15 -7
  10. package/dist/commands/context.js +329 -18
  11. package/dist/commands/doctor.js +116 -81
  12. package/dist/commands/init.js +202 -201
  13. package/dist/commands/login.js +134 -65
  14. package/dist/commands/logout.js +17 -6
  15. package/dist/commands/preview.js +29 -57
  16. package/dist/commands/project.js +120 -0
  17. package/dist/commands/projects.js +9 -2
  18. package/dist/commands/review.js +236 -202
  19. package/dist/commands/run.js +75 -72
  20. package/dist/commands/status.js +228 -150
  21. package/dist/commands/tdd-daemon-process.js +98 -0
  22. package/dist/commands/tdd-daemon.js +318 -212
  23. package/dist/commands/tdd.js +23 -8
  24. package/dist/commands/upload.js +10 -9
  25. package/dist/commands/whoami.js +63 -25
  26. package/dist/config/core.js +36 -35
  27. package/dist/context/local-workspace-provider.js +132 -12
  28. package/dist/context/provider-resolver.js +5 -1
  29. package/dist/index.js +5 -4
  30. package/dist/plugin-loader.js +55 -49
  31. package/dist/project/core.js +2 -2
  32. package/dist/project/operations.js +6 -3
  33. package/dist/reporter/reporter-bundle.css +1 -1
  34. package/dist/reporter/reporter-bundle.iife.js +7 -7
  35. package/dist/sdk/index.js +162 -107
  36. package/dist/server/handlers/tdd-handler.js +30 -54
  37. package/dist/server/routers/baseline.js +195 -178
  38. package/dist/server/routers/events.js +212 -174
  39. package/dist/server/routers/projects.js +14 -5
  40. package/dist/server/routers/screenshot.js +1 -31
  41. package/dist/services/build-manager.js +19 -97
  42. package/dist/services/config-service.js +26 -34
  43. package/dist/services/index.js +2 -4
  44. package/dist/services/static-report-generator.js +11 -12
  45. package/dist/services/test-runner.js +21 -27
  46. package/dist/tdd/core/region-coverage.js +109 -21
  47. package/dist/tdd/index.js +1 -1
  48. package/dist/tdd/metadata/region-metadata.js +0 -37
  49. package/dist/tdd/runtime-context.js +78 -0
  50. package/dist/tdd/server-registry.js +38 -17
  51. package/dist/tdd/services/baseline-manager.js +23 -0
  52. package/dist/tdd/services/comparison-service.js +10 -1
  53. package/dist/tdd/services/hotspot-service.js +0 -13
  54. package/dist/tdd/tdd-service.js +144 -138
  55. package/dist/types/client.d.ts +36 -1
  56. package/dist/types/index.d.ts +114 -22
  57. package/dist/types/sdk.d.ts +40 -0
  58. package/dist/uploader/core.js +5 -4
  59. package/dist/uploader/index.js +79 -2
  60. package/dist/uploader/operations.js +40 -5
  61. package/dist/utils/api-url.js +14 -0
  62. package/dist/utils/async-utils.js +26 -0
  63. package/dist/utils/ci-env.js +19 -12
  64. package/dist/utils/config-loader.js +39 -65
  65. package/dist/utils/config-schema.js +19 -55
  66. package/dist/utils/fetch-utils.js +12 -5
  67. package/dist/utils/git.js +35 -72
  68. package/dist/utils/global-config.js +20 -1
  69. package/dist/utils/output.js +19 -7
  70. package/dist/utils/patterns.js +14 -0
  71. package/dist/utils/project-link-store.js +105 -0
  72. package/dist/utils/screenshot-options.js +28 -0
  73. package/dist/utils/secret-store.js +65 -0
  74. package/package.json +14 -11
  75. package/dist/services/screenshot-server.js +0 -65
  76. package/dist/services/server-manager.js +0 -81
  77. package/dist/services/uploader.js +0 -100
  78. package/dist/tdd/services/region-service.js +0 -75
  79. package/dist/utils/build-history.js +0 -103
@@ -157,6 +157,16 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
157
157
  const tddService = new TddService(config, workingDir, setBaseline);
158
158
  const reportPath = join(workingDir, '.vizzly', 'report-data.json');
159
159
  const detailsPath = join(workingDir, '.vizzly', 'comparison-details.json');
160
+ let clearJsonFile = path => {
161
+ if (existsSync(path)) {
162
+ unlinkSync(path);
163
+ }
164
+ };
165
+ let prepareRunArtifacts = () => {
166
+ clearJsonFile(reportPath);
167
+ clearJsonFile(detailsPath);
168
+ tddService.clearRunData?.();
169
+ };
160
170
 
161
171
  /**
162
172
  * Read heavy comparison details from comparison-details.json
@@ -267,6 +277,7 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
267
277
  };
268
278
  const initialize = async () => {
269
279
  output.debug('tdd', 'initializing local mode');
280
+ prepareRunArtifacts();
270
281
 
271
282
  // In baseline update mode, skip all baseline loading/downloading
272
283
  if (setBaseline) {
@@ -631,68 +642,33 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
631
642
  let deletedBaselines = 0;
632
643
  let deletedCurrents = 0;
633
644
  let deletedDiffs = 0;
634
-
635
- // Delete all baseline, current, and diff images
636
- for (const comparison of reportData.comparisons) {
637
- // Delete baseline image if it exists
638
- if (comparison.baseline) {
639
- const baselinePath = join(workingDir, '.vizzly', comparison.baseline.replace('/images/', ''));
640
- if (existsSync(baselinePath)) {
641
- try {
642
- const {
643
- unlinkSync
644
- } = await import('node:fs');
645
- unlinkSync(baselinePath);
646
- deletedBaselines++;
647
- // Silent deletion
648
- } catch (error) {
649
- output.warn(`Failed to delete baseline for ${comparison.name}: ${error.message}`);
650
- }
651
- }
652
- }
653
-
654
- // Delete current screenshot if it exists
655
- if (comparison.current) {
656
- const currentPath = join(workingDir, '.vizzly', comparison.current.replace('/images/', ''));
657
- if (existsSync(currentPath)) {
658
- try {
659
- const {
660
- unlinkSync
661
- } = await import('node:fs');
662
- unlinkSync(currentPath);
663
- deletedCurrents++;
664
- // Silent deletion
665
- } catch (error) {
666
- output.warn(`Failed to delete current screenshot for ${comparison.name}: ${error.message}`);
667
- }
645
+ let deleteResetFile = (imagePath, label, name) => {
646
+ if (!imagePath) return 0;
647
+ try {
648
+ let filePath = safePath(workingDir, '.vizzly', imagePath.replace('/images/', ''));
649
+ if (!existsSync(filePath)) return 0;
650
+ unlinkSync(filePath);
651
+ if (label === 'diff') {
652
+ output.debug(`Deleted diff for ${name}`);
668
653
  }
654
+ return 1;
655
+ } catch (error) {
656
+ output.warn(`Failed to delete ${label} for ${name}: ${error.message}`);
657
+ return 0;
669
658
  }
659
+ };
670
660
 
671
- // Delete diff image if it exists
672
- if (comparison.diff) {
673
- const diffPath = join(workingDir, '.vizzly', comparison.diff.replace('/images/', ''));
674
- if (existsSync(diffPath)) {
675
- try {
676
- const {
677
- unlinkSync
678
- } = await import('node:fs');
679
- unlinkSync(diffPath);
680
- deletedDiffs++;
681
- output.debug(`Deleted diff for ${comparison.name}`);
682
- } catch (error) {
683
- output.warn(`Failed to delete diff for ${comparison.name}: ${error.message}`);
684
- }
685
- }
686
- }
661
+ // Delete all baseline, current, and diff images
662
+ for (let comparison of reportData.comparisons) {
663
+ deletedBaselines += deleteResetFile(comparison.baseline, 'baseline', comparison.name);
664
+ deletedCurrents += deleteResetFile(comparison.current, 'current screenshot', comparison.name);
665
+ deletedDiffs += deleteResetFile(comparison.diff, 'diff', comparison.name);
687
666
  }
688
667
 
689
668
  // Delete baseline metadata
690
- const metadataPath = join(workingDir, '.vizzly', 'baselines', 'metadata.json');
669
+ let metadataPath = join(workingDir, '.vizzly', 'baselines', 'metadata.json');
691
670
  if (existsSync(metadataPath)) {
692
671
  try {
693
- const {
694
- unlinkSync
695
- } = await import('node:fs');
696
672
  unlinkSync(metadataPath);
697
673
  output.debug('Deleted baseline metadata');
698
674
  } catch (error) {
@@ -6,6 +6,169 @@
6
6
  import * as output from '../../utils/output.js';
7
7
  import { parseJsonBody } from '../middleware/json-parser.js';
8
8
  import { sendError, sendServiceUnavailable, sendSuccess } from '../middleware/response.js';
9
+ function sendBaselineUnavailable(res) {
10
+ sendError(res, 400, 'Baseline management not available');
11
+ }
12
+ async function parseRequiredComparisonId(req, res) {
13
+ let {
14
+ id
15
+ } = await parseJsonBody(req);
16
+ if (!id) {
17
+ sendError(res, 400, 'Comparison ID required');
18
+ return null;
19
+ }
20
+ return id;
21
+ }
22
+ async function handleComparisonAction({
23
+ req,
24
+ res,
25
+ screenshotHandler,
26
+ actionName,
27
+ action,
28
+ successMessage
29
+ }) {
30
+ if (!screenshotHandler?.[actionName]) {
31
+ sendBaselineUnavailable(res);
32
+ return true;
33
+ }
34
+ try {
35
+ let id = await parseRequiredComparisonId(req, res);
36
+ if (!id) {
37
+ return true;
38
+ }
39
+ await screenshotHandler[actionName](id);
40
+ sendSuccess(res, {
41
+ success: true,
42
+ message: successMessage(id)
43
+ });
44
+ return true;
45
+ } catch (error) {
46
+ if (error.code === 'NOT_FOUND') {
47
+ sendError(res, 404, error.message);
48
+ } else {
49
+ output.error(`Error ${action}:`, error);
50
+ sendError(res, 500, error.message);
51
+ }
52
+ return true;
53
+ }
54
+ }
55
+ async function handleAcceptAllBaselines(res, screenshotHandler) {
56
+ if (!screenshotHandler?.acceptAllBaselines) {
57
+ sendBaselineUnavailable(res);
58
+ return true;
59
+ }
60
+ try {
61
+ let result = await screenshotHandler.acceptAllBaselines();
62
+ sendSuccess(res, {
63
+ success: true,
64
+ message: `Accepted ${result.count} baselines`,
65
+ count: result.count
66
+ });
67
+ return true;
68
+ } catch (error) {
69
+ output.error('Error accepting all baselines:', error);
70
+ sendError(res, 500, error.message);
71
+ return true;
72
+ }
73
+ }
74
+ async function handleResetBaselines(res, screenshotHandler) {
75
+ if (!screenshotHandler?.resetBaselines) {
76
+ sendBaselineUnavailable(res);
77
+ return true;
78
+ }
79
+ try {
80
+ await screenshotHandler.resetBaselines();
81
+ sendSuccess(res, {
82
+ success: true,
83
+ message: 'Baselines reset to previous state'
84
+ });
85
+ return true;
86
+ } catch (error) {
87
+ output.error('Error resetting baselines:', error);
88
+ sendError(res, 500, error.message);
89
+ return true;
90
+ }
91
+ }
92
+ function shouldFallbackFromOAuth(error) {
93
+ return error.message?.includes('Not authenticated') || error.message?.includes('401');
94
+ }
95
+ async function downloadBaselinesWithOAuth({
96
+ authService,
97
+ tddService,
98
+ buildId,
99
+ organizationSlug,
100
+ projectSlug
101
+ }) {
102
+ output.info(`Downloading baselines from build ${buildId}...`);
103
+ output.debug('baseline', `Using OAuth for ${organizationSlug}/${projectSlug}`);
104
+ let apiResponse = await authService.authenticatedRequest(`/api/cli/${projectSlug}/builds/${buildId}/tdd-baselines`, {
105
+ method: 'GET',
106
+ headers: {
107
+ 'X-Organization': organizationSlug
108
+ }
109
+ });
110
+ if (!apiResponse) {
111
+ throw new Error(`Build ${buildId} not found or API returned null`);
112
+ }
113
+ return tddService.processDownloadedBaselines(apiResponse, buildId);
114
+ }
115
+ async function downloadBaselinesWithToken(tddService, buildId) {
116
+ output.info(`Downloading baselines from build ${buildId}...`);
117
+ return tddService.downloadBaselines('test', null, buildId, null);
118
+ }
119
+ async function handleBaselineDownload({
120
+ req,
121
+ res,
122
+ tddService,
123
+ authService
124
+ }) {
125
+ if (!tddService) {
126
+ sendServiceUnavailable(res, 'TDD service not available (only available in TDD mode)');
127
+ return true;
128
+ }
129
+ try {
130
+ let body = await parseJsonBody(req);
131
+ let {
132
+ buildId,
133
+ organizationSlug,
134
+ projectSlug
135
+ } = body;
136
+ if (!buildId) {
137
+ sendError(res, 400, 'buildId is required');
138
+ return true;
139
+ }
140
+ let result;
141
+ if (authService && organizationSlug && projectSlug) {
142
+ try {
143
+ result = await downloadBaselinesWithOAuth({
144
+ authService,
145
+ tddService,
146
+ buildId,
147
+ organizationSlug,
148
+ projectSlug
149
+ });
150
+ } catch (oauthError) {
151
+ if (!shouldFallbackFromOAuth(oauthError)) {
152
+ throw oauthError;
153
+ }
154
+ output.debug('baseline', `OAuth failed, trying other auth methods: ${oauthError.message}`);
155
+ }
156
+ }
157
+ if (!result) {
158
+ result = await downloadBaselinesWithToken(tddService, buildId);
159
+ }
160
+ sendSuccess(res, {
161
+ success: true,
162
+ message: `Baselines downloaded from build ${buildId}`,
163
+ ...result
164
+ });
165
+ return true;
166
+ } catch (error) {
167
+ output.error('Error downloading baselines:', error);
168
+ sendError(res, 500, error.message);
169
+ return true;
170
+ }
171
+ }
9
172
 
10
173
  /**
11
174
  * Create baseline router
@@ -23,204 +186,58 @@ export function createBaselineRouter({
23
186
  return async function handleBaselineRoute(req, res, pathname) {
24
187
  // Accept a single screenshot as baseline
25
188
  if (req.method === 'POST' && pathname === '/api/baseline/accept') {
26
- if (!screenshotHandler?.acceptBaseline) {
27
- sendError(res, 400, 'Baseline management not available');
28
- return true;
29
- }
30
- try {
31
- const {
32
- id
33
- } = await parseJsonBody(req);
34
- if (!id) {
35
- sendError(res, 400, 'Comparison ID required');
36
- return true;
37
- }
38
- await screenshotHandler.acceptBaseline(id);
39
- sendSuccess(res, {
40
- success: true,
41
- message: `Baseline accepted for comparison ${id}`
42
- });
43
- return true;
44
- } catch (error) {
45
- output.error('Error accepting baseline:', error);
46
- sendError(res, 500, error.message);
47
- return true;
48
- }
189
+ return handleComparisonAction({
190
+ req,
191
+ res,
192
+ screenshotHandler,
193
+ actionName: 'acceptBaseline',
194
+ action: 'accepting baseline',
195
+ successMessage: id => `Baseline accepted for comparison ${id}`
196
+ });
49
197
  }
50
198
 
51
199
  // Accept all screenshots as baseline
52
200
  if (req.method === 'POST' && pathname === '/api/baseline/accept-all') {
53
- if (!screenshotHandler?.acceptAllBaselines) {
54
- sendError(res, 400, 'Baseline management not available');
55
- return true;
56
- }
57
- try {
58
- const result = await screenshotHandler.acceptAllBaselines();
59
- sendSuccess(res, {
60
- success: true,
61
- message: `Accepted ${result.count} baselines`,
62
- count: result.count
63
- });
64
- return true;
65
- } catch (error) {
66
- output.error('Error accepting all baselines:', error);
67
- sendError(res, 500, error.message);
68
- return true;
69
- }
201
+ return handleAcceptAllBaselines(res, screenshotHandler);
70
202
  }
71
203
 
72
204
  // Reject a single comparison (keep current baseline, discard changes)
73
205
  if (req.method === 'POST' && pathname === '/api/baseline/reject') {
74
- if (!screenshotHandler?.rejectBaseline) {
75
- sendError(res, 400, 'Baseline management not available');
76
- return true;
77
- }
78
- try {
79
- const {
80
- id
81
- } = await parseJsonBody(req);
82
- if (!id) {
83
- sendError(res, 400, 'Comparison ID required');
84
- return true;
85
- }
86
- await screenshotHandler.rejectBaseline(id);
87
- sendSuccess(res, {
88
- success: true,
89
- message: `Changes rejected for comparison ${id}`
90
- });
91
- return true;
92
- } catch (error) {
93
- output.error('Error rejecting baseline:', error);
94
- sendError(res, 500, error.message);
95
- return true;
96
- }
206
+ return handleComparisonAction({
207
+ req,
208
+ res,
209
+ screenshotHandler,
210
+ actionName: 'rejectBaseline',
211
+ action: 'rejecting baseline',
212
+ successMessage: id => `Changes rejected for comparison ${id}`
213
+ });
97
214
  }
98
215
 
99
216
  // Delete a comparison entirely (removes from report and deletes files)
100
217
  if (req.method === 'POST' && pathname === '/api/baseline/delete') {
101
- if (!screenshotHandler?.deleteComparison) {
102
- sendError(res, 400, 'Baseline management not available');
103
- return true;
104
- }
105
- try {
106
- const {
107
- id
108
- } = await parseJsonBody(req);
109
- if (!id) {
110
- sendError(res, 400, 'Comparison ID required');
111
- return true;
112
- }
113
- await screenshotHandler.deleteComparison(id);
114
- sendSuccess(res, {
115
- success: true,
116
- message: `Comparison ${id} deleted`
117
- });
118
- return true;
119
- } catch (error) {
120
- if (error.code === 'NOT_FOUND') {
121
- sendError(res, 404, error.message);
122
- } else {
123
- output.error('Error deleting comparison:', error);
124
- sendError(res, 500, error.message);
125
- }
126
- return true;
127
- }
218
+ return handleComparisonAction({
219
+ req,
220
+ res,
221
+ screenshotHandler,
222
+ actionName: 'deleteComparison',
223
+ action: 'deleting comparison',
224
+ successMessage: id => `Comparison ${id} deleted`
225
+ });
128
226
  }
129
227
 
130
228
  // Reset baselines to previous state
131
229
  if (req.method === 'POST' && pathname === '/api/baseline/reset') {
132
- if (!screenshotHandler?.resetBaselines) {
133
- sendError(res, 400, 'Baseline management not available');
134
- return true;
135
- }
136
- try {
137
- await screenshotHandler.resetBaselines();
138
- sendSuccess(res, {
139
- success: true,
140
- message: 'Baselines reset to previous state'
141
- });
142
- return true;
143
- } catch (error) {
144
- output.error('Error resetting baselines:', error);
145
- sendError(res, 500, error.message);
146
- return true;
147
- }
230
+ return handleResetBaselines(res, screenshotHandler);
148
231
  }
149
232
 
150
233
  // Download baselines from a remote build
151
234
  if (req.method === 'POST' && pathname === '/api/baselines/download') {
152
- if (!tddService) {
153
- sendServiceUnavailable(res, 'TDD service not available (only available in TDD mode)');
154
- return true;
155
- }
156
- try {
157
- let body = await parseJsonBody(req);
158
- let {
159
- buildId,
160
- organizationSlug,
161
- projectSlug
162
- } = body;
163
- if (!buildId) {
164
- sendError(res, 400, 'buildId is required');
165
- return true;
166
- }
167
-
168
- // Try OAuth authentication first (allows access to any project user has membership to)
169
- // This is the preferred method when user is logged in via the dashboard
170
- if (authService && organizationSlug && projectSlug) {
171
- try {
172
- output.info(`Downloading baselines from build ${buildId}...`);
173
- output.debug('baseline', `Using OAuth for ${organizationSlug}/${projectSlug}`);
174
-
175
- // Use the CLI endpoint which accepts OAuth and checks project membership
176
- let apiResponse = await authService.authenticatedRequest(`/api/cli/${projectSlug}/builds/${buildId}/tdd-baselines`, {
177
- method: 'GET',
178
- headers: {
179
- 'X-Organization': organizationSlug
180
- }
181
- });
182
- if (!apiResponse) {
183
- throw new Error(`Build ${buildId} not found or API returned null`);
184
- }
185
-
186
- // Process the baselines through tddService
187
- let result = await tddService.processDownloadedBaselines(apiResponse, buildId);
188
- sendSuccess(res, {
189
- success: true,
190
- message: `Baselines downloaded from build ${buildId}`,
191
- ...result
192
- });
193
- return true;
194
- } catch (oauthError) {
195
- // If OAuth fails with auth error, fall through to other methods
196
- if (!oauthError.message?.includes('Not authenticated') && !oauthError.message?.includes('401')) {
197
- throw oauthError;
198
- }
199
- output.debug('baseline', `OAuth failed, trying other auth methods: ${oauthError.message}`);
200
- }
201
- }
202
-
203
- // Fall back to using tddService directly (requires VIZZLY_TOKEN env var)
204
- output.info(`Downloading baselines from build ${buildId}...`);
205
- let result = await tddService.downloadBaselines('test',
206
- // environment
207
- null,
208
- // branch (not needed when buildId is specified)
209
- buildId,
210
- // specific build to download from
211
- null // comparisonId (not needed)
212
- );
213
- sendSuccess(res, {
214
- success: true,
215
- message: `Baselines downloaded from build ${buildId}`,
216
- ...result
217
- });
218
- return true;
219
- } catch (error) {
220
- output.error('Error downloading baselines:', error);
221
- sendError(res, 500, error.message);
222
- return true;
223
- }
235
+ return handleBaselineDownload({
236
+ req,
237
+ res,
238
+ tddService,
239
+ authService
240
+ });
224
241
  }
225
242
  return false;
226
243
  };