@vizzly-testing/cli 0.30.0 → 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 (77) 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/index.js +1 -1
  47. package/dist/tdd/metadata/region-metadata.js +0 -37
  48. package/dist/tdd/runtime-context.js +78 -0
  49. package/dist/tdd/server-registry.js +38 -17
  50. package/dist/tdd/services/baseline-manager.js +23 -0
  51. package/dist/tdd/services/hotspot-service.js +0 -13
  52. package/dist/tdd/tdd-service.js +82 -113
  53. package/dist/types/client.d.ts +36 -1
  54. package/dist/types/index.d.ts +114 -22
  55. package/dist/types/sdk.d.ts +40 -0
  56. package/dist/uploader/core.js +5 -4
  57. package/dist/uploader/index.js +79 -2
  58. package/dist/uploader/operations.js +40 -5
  59. package/dist/utils/api-url.js +14 -0
  60. package/dist/utils/async-utils.js +26 -0
  61. package/dist/utils/ci-env.js +19 -12
  62. package/dist/utils/config-loader.js +39 -65
  63. package/dist/utils/config-schema.js +19 -55
  64. package/dist/utils/fetch-utils.js +12 -5
  65. package/dist/utils/git.js +35 -72
  66. package/dist/utils/global-config.js +20 -1
  67. package/dist/utils/output.js +19 -7
  68. package/dist/utils/patterns.js +14 -0
  69. package/dist/utils/project-link-store.js +105 -0
  70. package/dist/utils/screenshot-options.js +28 -0
  71. package/dist/utils/secret-store.js +65 -0
  72. package/package.json +11 -8
  73. package/dist/services/screenshot-server.js +0 -65
  74. package/dist/services/server-manager.js +0 -81
  75. package/dist/services/uploader.js +0 -100
  76. package/dist/tdd/services/region-service.js +0 -75
  77. package/dist/utils/build-history.js +0 -103
@@ -9,131 +9,207 @@ let FILE_WATCH_DEBOUNCE_MS = 25;
9
9
  let FILE_POLL_INTERVAL_MS = 50;
10
10
  let REPORT_READ_RETRY_MS = 25;
11
11
  let MAX_REPORT_READ_RETRIES = 3;
12
+ let defaultTimers = {
13
+ setTimeout,
14
+ clearTimeout,
15
+ setInterval,
16
+ clearInterval
17
+ };
12
18
 
13
19
  /**
14
- * Create events router for SSE
15
- * @param {Object} context - Router context
16
- * @param {string} context.workingDir - Working directory for report data
17
- * @returns {Function} Route handler
20
+ * Read and parse JSON from disk, returning null on missing or invalid files.
18
21
  */
19
- export function createEventsRouter(context) {
20
- const {
21
- workingDir = process.cwd()
22
- } = context || {};
23
- const reportDataPath = join(workingDir, '.vizzly', 'report-data.json');
24
- const baselineMetadataPath = join(workingDir, '.vizzly', 'baselines', 'metadata.json');
22
+ export function readJsonFile(path) {
23
+ if (!existsSync(path)) {
24
+ return null;
25
+ }
26
+ try {
27
+ return JSON.parse(readFileSync(path, 'utf8'));
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
25
32
 
26
- /**
27
- * Read and parse baseline metadata, returning null on error
28
- */
29
- const readBaselineMetadata = () => {
30
- if (!existsSync(baselineMetadataPath)) {
31
- return null;
32
- }
33
- try {
34
- return JSON.parse(readFileSync(baselineMetadataPath, 'utf8'));
35
- } catch {
36
- return null;
37
- }
33
+ /**
34
+ * Read report data and attach baseline metadata for the stats view.
35
+ */
36
+ export function readReportDataFile({
37
+ reportDataPath,
38
+ baselineMetadataPath
39
+ }) {
40
+ let data = readJsonFile(reportDataPath);
41
+ if (!data) {
42
+ return null;
43
+ }
44
+ return {
45
+ ...data,
46
+ baseline: readJsonFile(baselineMetadataPath)
38
47
  };
48
+ }
39
49
 
40
- /**
41
- * Read and parse report data with baseline metadata included
42
- */
43
- const readReportData = () => {
44
- if (!existsSync(reportDataPath)) {
45
- return null;
50
+ /**
51
+ * Build a lookup map from comparisons array keyed by id
52
+ */
53
+ function buildComparisonMap(comparisons) {
54
+ let map = new Map();
55
+ for (let c of comparisons) {
56
+ map.set(c.id, c);
57
+ }
58
+ return map;
59
+ }
60
+ function comparisonChanged(oldComp, newComp) {
61
+ return JSON.stringify(oldComp) !== JSON.stringify(newComp);
62
+ }
63
+
64
+ /**
65
+ * Extract summary fields (everything except comparisons) for diffing
66
+ */
67
+ function extractSummary(data) {
68
+ let {
69
+ comparisons: _c,
70
+ ...summary
71
+ } = data;
72
+ return summary;
73
+ }
74
+
75
+ /**
76
+ * Check if summary-level fields changed between old and new data
77
+ */
78
+ function summaryChanged(oldData, newData) {
79
+ let oldSummary = extractSummary(oldData);
80
+ let newSummary = extractSummary(newData);
81
+ return JSON.stringify(oldSummary) !== JSON.stringify(newSummary);
82
+ }
83
+
84
+ /**
85
+ * Build incremental SSE events by diffing old vs new report data.
86
+ */
87
+ export function buildReportDataEvents(oldData, newData) {
88
+ if (!oldData) {
89
+ return [{
90
+ type: 'reportData',
91
+ data: newData
92
+ }];
93
+ }
94
+ let events = [];
95
+ let oldComparisons = oldData.comparisons || [];
96
+ let newComparisons = newData.comparisons || [];
97
+ let oldMap = buildComparisonMap(oldComparisons);
98
+ let newMap = buildComparisonMap(newComparisons);
99
+
100
+ // New or changed comparisons send the full comparison object, not a partial delta.
101
+ for (let [id, newComp] of newMap) {
102
+ let oldComp = oldMap.get(id);
103
+ if (!oldComp || comparisonChanged(oldComp, newComp)) {
104
+ events.push({
105
+ type: 'comparisonUpdate',
106
+ data: newComp
107
+ });
108
+ }
109
+ }
110
+ for (let [id] of oldMap) {
111
+ if (!newMap.has(id)) {
112
+ events.push({
113
+ type: 'comparisonRemoved',
114
+ data: {
115
+ id
116
+ }
117
+ });
46
118
  }
119
+ }
120
+ if (summaryChanged(oldData, newData)) {
121
+ events.push({
122
+ type: 'summaryUpdate',
123
+ data: extractSummary(newData)
124
+ });
125
+ }
126
+ return events;
127
+ }
128
+
129
+ /**
130
+ * Watch report-data.json with fs.watch plus a lightweight mtime fallback.
131
+ */
132
+ export function watchReportDataFile({
133
+ workingDir,
134
+ reportDataPath,
135
+ onReportDataChanged,
136
+ timers = defaultTimers
137
+ }) {
138
+ let watcher = null;
139
+ let filePollInterval = null;
140
+ let vizzlyDir = join(workingDir, '.vizzly');
141
+ if (existsSync(vizzlyDir)) {
47
142
  try {
48
- const data = JSON.parse(readFileSync(reportDataPath, 'utf8'));
49
- // Include baseline metadata for stats view
50
- data.baseline = readBaselineMetadata();
51
- return data;
143
+ watcher = watch(vizzlyDir, {
144
+ recursive: false
145
+ }, (_eventType, filename) => {
146
+ // Some platforms occasionally omit the filename for directory watch
147
+ // events. In that case, fall back to re-reading report data.
148
+ if (!filename || filename === 'report-data.json') {
149
+ onReportDataChanged();
150
+ }
151
+ });
152
+ watcher.on('error', () => {
153
+ if (watcher) {
154
+ watcher.close();
155
+ watcher = null;
156
+ }
157
+ });
52
158
  } catch {
53
- return null;
159
+ // File watching not available, mtime polling remains as fallback.
160
+ }
161
+ }
162
+ let lastPolledMtime = existsSync(reportDataPath) ? statSync(reportDataPath).mtimeMs : null;
163
+ filePollInterval = timers.setInterval(() => {
164
+ let nextMtime = null;
165
+ if (existsSync(reportDataPath)) {
166
+ try {
167
+ nextMtime = statSync(reportDataPath).mtimeMs;
168
+ } catch {
169
+ nextMtime = null;
170
+ }
171
+ }
172
+ if (nextMtime !== lastPolledMtime) {
173
+ lastPolledMtime = nextMtime;
174
+ onReportDataChanged();
175
+ }
176
+ }, FILE_POLL_INTERVAL_MS);
177
+ return () => {
178
+ if (watcher) {
179
+ watcher.close();
180
+ watcher = null;
181
+ }
182
+ if (filePollInterval) {
183
+ timers.clearInterval(filePollInterval);
184
+ filePollInterval = null;
54
185
  }
55
186
  };
187
+ }
188
+
189
+ /**
190
+ * Create events router for SSE
191
+ * @param {Object} context - Router context
192
+ * @param {string} context.workingDir - Working directory for report data
193
+ * @returns {Function} Route handler
194
+ */
195
+ export function createEventsRouter(context) {
196
+ let {
197
+ workingDir = process.cwd(),
198
+ readReportData = readReportDataFile,
199
+ watchReportData = watchReportDataFile,
200
+ timers = defaultTimers
201
+ } = context || {};
202
+ let reportDataPath = join(workingDir, '.vizzly', 'report-data.json');
203
+ let baselineMetadataPath = join(workingDir, '.vizzly', 'baselines', 'metadata.json');
56
204
 
57
205
  /**
58
206
  * Send SSE event to response
59
207
  */
60
- const sendEvent = (res, eventType, data) => {
208
+ let sendEvent = (res, eventType, data) => {
61
209
  if (res.writableEnded) return;
62
210
  res.write(`event: ${eventType}\n`);
63
211
  res.write(`data: ${JSON.stringify(data)}\n\n`);
64
212
  };
65
-
66
- /**
67
- * Build a lookup map from comparisons array keyed by id
68
- */
69
- const buildComparisonMap = comparisons => {
70
- let map = new Map();
71
- for (let c of comparisons) {
72
- map.set(c.id, c);
73
- }
74
- return map;
75
- };
76
- const comparisonChanged = (oldComp, newComp) => {
77
- return JSON.stringify(oldComp) !== JSON.stringify(newComp);
78
- };
79
-
80
- /**
81
- * Extract summary fields (everything except comparisons) for diffing
82
- */
83
- const extractSummary = data => {
84
- let {
85
- comparisons: _c,
86
- ...summary
87
- } = data;
88
- return summary;
89
- };
90
-
91
- /**
92
- * Check if summary-level fields changed between old and new data
93
- */
94
- const summaryChanged = (oldData, newData) => {
95
- let oldSummary = extractSummary(oldData);
96
- let newSummary = extractSummary(newData);
97
- return JSON.stringify(oldSummary) !== JSON.stringify(newSummary);
98
- };
99
-
100
- /**
101
- * Send incremental updates by diffing old vs new report data.
102
- * Returns true if any events were sent.
103
- */
104
- const sendIncrementalUpdates = (res, oldData, newData) => {
105
- let sent = false;
106
- let oldComparisons = oldData.comparisons || [];
107
- let newComparisons = newData.comparisons || [];
108
- let oldMap = buildComparisonMap(oldComparisons);
109
- let newMap = buildComparisonMap(newComparisons);
110
-
111
- // New or changed comparisons — sends the full comparison object, not a partial delta
112
- for (let [id, newComp] of newMap) {
113
- let oldComp = oldMap.get(id);
114
- if (!oldComp || comparisonChanged(oldComp, newComp)) {
115
- sendEvent(res, 'comparisonUpdate', newComp);
116
- sent = true;
117
- }
118
- }
119
-
120
- // Removed comparisons
121
- for (let [id] of oldMap) {
122
- if (!newMap.has(id)) {
123
- sendEvent(res, 'comparisonRemoved', {
124
- id
125
- });
126
- sent = true;
127
- }
128
- }
129
-
130
- // Summary-level changes (total, passed, failed, etc.)
131
- if (summaryChanged(oldData, newData)) {
132
- sendEvent(res, 'summaryUpdate', extractSummary(newData));
133
- sent = true;
134
- }
135
- return sent;
136
- };
137
213
  return async function handleEventsRoute(req, res, pathname) {
138
214
  if (req.method !== 'GET' || pathname !== '/api/events') {
139
215
  return false;
@@ -148,82 +224,51 @@ export function createEventsRouter(context) {
148
224
  });
149
225
 
150
226
  // Send initial full data immediately
151
- let lastSentData = readReportData();
227
+ let lastSentData = readReportData({
228
+ reportDataPath,
229
+ baselineMetadataPath
230
+ });
152
231
  if (lastSentData) {
153
232
  sendEvent(res, 'reportData', lastSentData);
154
233
  }
155
234
 
156
235
  // Debounce file change events (fs.watch can fire multiple times)
157
236
  let debounceTimer = null;
158
- let watcher = null;
159
- let filePollInterval = null;
160
- const scheduleUpdate = () => {
237
+ let scheduleUpdate = () => {
161
238
  if (debounceTimer) {
162
- clearTimeout(debounceTimer);
239
+ timers.clearTimeout(debounceTimer);
163
240
  }
164
- debounceTimer = setTimeout(sendUpdate, FILE_WATCH_DEBOUNCE_MS);
241
+ debounceTimer = timers.setTimeout(sendUpdate, FILE_WATCH_DEBOUNCE_MS);
165
242
  };
166
- const sendUpdate = (retryCount = 0) => {
167
- const newData = readReportData();
243
+ let sendUpdate = (retryCount = 0) => {
244
+ let newData = readReportData({
245
+ reportDataPath,
246
+ baselineMetadataPath
247
+ });
168
248
  if (!newData) {
169
249
  if (existsSync(reportDataPath) && retryCount < MAX_REPORT_READ_RETRIES) {
170
- debounceTimer = setTimeout(() => sendUpdate(retryCount + 1), REPORT_READ_RETRY_MS);
250
+ debounceTimer = timers.setTimeout(() => sendUpdate(retryCount + 1), REPORT_READ_RETRY_MS);
171
251
  }
172
252
  return;
173
253
  }
174
- if (!lastSentData) {
175
- // No previous data — send full payload
176
- sendEvent(res, 'reportData', newData);
177
- } else {
178
- // Diff and send incremental updates
179
- let sent = sendIncrementalUpdates(res, lastSentData, newData);
180
- // If nothing changed, skip (no event needed)
181
- if (!sent) return;
254
+ let events = buildReportDataEvents(lastSentData, newData);
255
+ if (events.length === 0) {
256
+ return;
257
+ }
258
+ for (let event of events) {
259
+ sendEvent(res, event.type, event.data);
182
260
  }
183
261
  lastSentData = newData;
184
262
  };
185
-
186
- // Watch for file changes
187
- const vizzlyDir = join(workingDir, '.vizzly');
188
- if (existsSync(vizzlyDir)) {
189
- try {
190
- watcher = watch(vizzlyDir, {
191
- recursive: false
192
- }, (_eventType, filename) => {
193
- // Some platforms occasionally omit the filename for directory watch
194
- // events. In that case, fall back to re-reading report data.
195
- if (!filename || filename === 'report-data.json') {
196
- scheduleUpdate();
197
- }
198
- });
199
- watcher.on('error', () => {
200
- if (watcher) {
201
- watcher.close();
202
- watcher = null;
203
- }
204
- });
205
- } catch {
206
- // File watching not available, client will fall back to polling
207
- }
208
- }
209
- let lastPolledMtime = existsSync(reportDataPath) ? statSync(reportDataPath).mtimeMs : null;
210
- filePollInterval = setInterval(() => {
211
- let nextMtime = null;
212
- if (existsSync(reportDataPath)) {
213
- try {
214
- nextMtime = statSync(reportDataPath).mtimeMs;
215
- } catch {
216
- nextMtime = null;
217
- }
218
- }
219
- if (nextMtime !== lastPolledMtime) {
220
- lastPolledMtime = nextMtime;
221
- scheduleUpdate();
222
- }
223
- }, FILE_POLL_INTERVAL_MS);
263
+ let cleanupReportWatcher = watchReportData({
264
+ workingDir,
265
+ reportDataPath,
266
+ onReportDataChanged: scheduleUpdate,
267
+ timers
268
+ });
224
269
 
225
270
  // Heartbeat to keep connection alive (every 30 seconds)
226
- const heartbeatInterval = setInterval(() => {
271
+ let heartbeatInterval = timers.setInterval(() => {
227
272
  if (!res.writableEnded) {
228
273
  sendEvent(res, 'heartbeat', {
229
274
  timestamp: Date.now()
@@ -232,20 +277,13 @@ export function createEventsRouter(context) {
232
277
  }, 30000);
233
278
 
234
279
  // Cleanup on connection close
235
- const cleanup = () => {
280
+ let cleanup = () => {
236
281
  if (debounceTimer) {
237
- clearTimeout(debounceTimer);
282
+ timers.clearTimeout(debounceTimer);
238
283
  debounceTimer = null;
239
284
  }
240
- clearInterval(heartbeatInterval);
241
- if (watcher) {
242
- watcher.close();
243
- watcher = null;
244
- }
245
- if (filePollInterval) {
246
- clearInterval(filePollInterval);
247
- filePollInterval = null;
248
- }
285
+ timers.clearInterval(heartbeatInterval);
286
+ cleanupReportWatcher();
249
287
  };
250
288
  req.on('close', cleanup);
251
289
  req.on('error', cleanup);
@@ -43,11 +43,15 @@ export function createProjectsRouter({
43
43
  const projectBuildsMatch = pathname.match(/^\/api\/projects\/([^/]+)\/([^/]+)\/builds$/);
44
44
  if (req.method === 'GET' && projectBuildsMatch) {
45
45
  try {
46
- const organizationSlug = decodeURIComponent(projectBuildsMatch[1]);
47
- const projectSlug = decodeURIComponent(projectBuildsMatch[2]);
48
- const limit = parseInt(parsedUrl.searchParams.get('limit') || '20', 10);
49
- const branch = parsedUrl.searchParams.get('branch') || undefined;
50
- const builds = await projectService.getRecentBuilds(projectSlug, organizationSlug, {
46
+ let organizationSlug = decodeURIComponent(projectBuildsMatch[1]);
47
+ let projectSlug = decodeURIComponent(projectBuildsMatch[2]);
48
+ let limit = parseLimitParam(parsedUrl.searchParams.get('limit'));
49
+ if (limit === null) {
50
+ sendError(res, 400, 'limit must be a positive integer');
51
+ return true;
52
+ }
53
+ let branch = parsedUrl.searchParams.get('branch') || undefined;
54
+ let builds = await projectService.getRecentBuilds(projectSlug, organizationSlug, {
51
55
  limit,
52
56
  branch
53
57
  });
@@ -65,4 +69,9 @@ export function createProjectsRouter({
65
69
  }
66
70
  return false;
67
71
  };
72
+ }
73
+ function parseLimitParam(value) {
74
+ if (value === null) return 20;
75
+ if (!/^[1-9]\d*$/.test(value)) return null;
76
+ return Number(value);
68
77
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Screenshot Router
3
- * Handles screenshot uploads and legacy baseline accept
3
+ * Handles screenshot uploads and flush requests.
4
4
  */
5
5
 
6
6
  import * as output from '../../utils/output.js';
@@ -85,36 +85,6 @@ export function createScreenshotRouter({
85
85
  return true;
86
86
  }
87
87
  }
88
-
89
- // Legacy accept-baseline endpoint
90
- if (pathname === '/accept-baseline') {
91
- try {
92
- const body = await parseJsonBody(req);
93
- const {
94
- id
95
- } = body;
96
- if (!id) {
97
- sendError(res, 400, 'comparison ID is required');
98
- return true;
99
- }
100
- if (screenshotHandler.acceptBaseline) {
101
- const result = await screenshotHandler.acceptBaseline(id);
102
- sendJson(res, 200, {
103
- success: true,
104
- ...result
105
- });
106
- } else {
107
- sendError(res, 501, 'Accept baseline not implemented');
108
- }
109
- return true;
110
- } catch (error) {
111
- output.debug('Accept baseline error:', {
112
- error: error.message
113
- });
114
- sendError(res, 500, 'Failed to accept baseline');
115
- return true;
116
- }
117
- }
118
88
  return false;
119
89
  };
120
90
  }
@@ -1,120 +1,42 @@
1
1
  /**
2
- * Build Manager - Pure functions for build lifecycle management
2
+ * Build Manager - local build object creation for the test runner.
3
3
  */
4
4
 
5
- import crypto from 'node:crypto';
5
+ import { randomUUID } from 'node:crypto';
6
6
 
7
7
  /**
8
- * Generate unique build ID for local build management only.
9
- * Note: The API generates its own UUIDs for actual builds - this local ID
10
- * is only used for CLI internal tracking and is not sent to the API.
11
- * @returns {string} Build ID
12
- */
13
- export function generateBuildId() {
14
- return `build-${crypto.randomUUID()}`;
15
- }
16
-
17
- /**
18
- * Create build object
8
+ * Create a local build object for test-runner orchestration.
9
+ *
10
+ * The API creates persisted build IDs. This object is only used inside the
11
+ * CLI process to give the runner a consistent shape before API creation.
12
+ *
19
13
  * @param {Object} buildOptions - Build configuration
14
+ * @param {Object} [deps]
15
+ * @param {Function} [deps.randomId]
16
+ * @param {Function} [deps.now]
17
+ * @param {Function} [deps.timestamp]
20
18
  * @returns {Object} Build object
21
19
  */
22
- export function createBuildObject(buildOptions) {
23
- const {
20
+ export function createBuildObject(buildOptions, deps = {}) {
21
+ let {
24
22
  name,
25
23
  branch,
26
24
  commit,
27
25
  environment = 'test',
28
26
  metadata = {}
29
27
  } = buildOptions;
28
+ let randomId = deps.randomId || randomUUID;
29
+ let now = deps.now || (() => new Date().toISOString());
30
+ let timestamp = deps.timestamp || Date.now;
30
31
  return {
31
- id: generateBuildId(),
32
- name: name || `build-${Date.now()}`,
32
+ id: `build-${randomId()}`,
33
+ name: name || `build-${timestamp()}`,
33
34
  branch,
34
35
  commit,
35
36
  environment,
36
37
  metadata,
37
38
  status: 'pending',
38
- createdAt: new Date().toISOString(),
39
+ createdAt: now(),
39
40
  screenshots: []
40
41
  };
41
- }
42
-
43
- /**
44
- * Update build with new status and data
45
- * @param {Object} build - Current build
46
- * @param {string} status - New status
47
- * @param {Object} updates - Additional updates
48
- * @returns {Object} Updated build
49
- */
50
- export function updateBuild(build, status, updates = {}) {
51
- return {
52
- ...build,
53
- status,
54
- updatedAt: new Date().toISOString(),
55
- ...updates
56
- };
57
- }
58
-
59
- /**
60
- * Add screenshot to build
61
- * @param {Object} build - Current build
62
- * @param {Object} screenshot - Screenshot data
63
- * @returns {Object} Updated build
64
- */
65
- export function addScreenshotToBuild(build, screenshot) {
66
- return {
67
- ...build,
68
- screenshots: [...build.screenshots, {
69
- ...screenshot,
70
- addedAt: new Date().toISOString()
71
- }]
72
- };
73
- }
74
-
75
- /**
76
- * Finalize build with result
77
- * @param {Object} build - Current build
78
- * @param {Object} result - Build result
79
- * @returns {Object} Finalized build
80
- */
81
- export function finalizeBuildObject(build, result = {}) {
82
- const finalStatus = result.success ? 'completed' : 'failed';
83
- return {
84
- ...build,
85
- status: finalStatus,
86
- completedAt: new Date().toISOString(),
87
- result
88
- };
89
- }
90
-
91
- /**
92
- * Create queued build item
93
- * @param {Object} buildOptions - Build options
94
- * @returns {Object} Queued build item
95
- */
96
- export function createQueuedBuild(buildOptions) {
97
- return {
98
- ...buildOptions,
99
- queuedAt: new Date().toISOString()
100
- };
101
- }
102
-
103
- /**
104
- * Validate build options
105
- * @param {Object} buildOptions - Build options to validate
106
- * @returns {Object} Validation result
107
- */
108
- export function validateBuildOptions(buildOptions) {
109
- let errors = [];
110
- if (!buildOptions.name && !buildOptions.branch) {
111
- errors.push('Either name or branch is required');
112
- }
113
- if (buildOptions.environment && !['test', 'staging', 'production'].includes(buildOptions.environment)) {
114
- errors.push('Environment must be one of: test, staging, production');
115
- }
116
- return {
117
- valid: errors.length === 0,
118
- errors
119
- };
120
42
  }