@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
@@ -3,133 +3,213 @@
3
3
  * Server-Sent Events endpoint for real-time dashboard updates
4
4
  */
5
5
 
6
- import { existsSync, readFileSync, watch } from 'node:fs';
6
+ import { existsSync, readFileSync, statSync, watch } from 'node:fs';
7
7
  import { join } from 'node:path';
8
+ let FILE_WATCH_DEBOUNCE_MS = 25;
9
+ let FILE_POLL_INTERVAL_MS = 50;
10
+ let REPORT_READ_RETRY_MS = 25;
11
+ let MAX_REPORT_READ_RETRIES = 3;
12
+ let defaultTimers = {
13
+ setTimeout,
14
+ clearTimeout,
15
+ setInterval,
16
+ clearInterval
17
+ };
8
18
 
9
19
  /**
10
- * Create events router for SSE
11
- * @param {Object} context - Router context
12
- * @param {string} context.workingDir - Working directory for report data
13
- * @returns {Function} Route handler
20
+ * Read and parse JSON from disk, returning null on missing or invalid files.
14
21
  */
15
- export function createEventsRouter(context) {
16
- const {
17
- workingDir = process.cwd()
18
- } = context || {};
19
- const reportDataPath = join(workingDir, '.vizzly', 'report-data.json');
20
- 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
+ }
21
32
 
22
- /**
23
- * Read and parse baseline metadata, returning null on error
24
- */
25
- const readBaselineMetadata = () => {
26
- if (!existsSync(baselineMetadataPath)) {
27
- return null;
28
- }
29
- try {
30
- return JSON.parse(readFileSync(baselineMetadataPath, 'utf8'));
31
- } catch {
32
- return null;
33
- }
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)
34
47
  };
48
+ }
35
49
 
36
- /**
37
- * Read and parse report data with baseline metadata included
38
- */
39
- const readReportData = () => {
40
- if (!existsSync(reportDataPath)) {
41
- 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
+ });
42
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)) {
43
142
  try {
44
- const data = JSON.parse(readFileSync(reportDataPath, 'utf8'));
45
- // Include baseline metadata for stats view
46
- data.baseline = readBaselineMetadata();
47
- 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
+ });
48
158
  } catch {
49
- 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;
50
185
  }
51
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');
52
204
 
53
205
  /**
54
206
  * Send SSE event to response
55
207
  */
56
- const sendEvent = (res, eventType, data) => {
208
+ let sendEvent = (res, eventType, data) => {
57
209
  if (res.writableEnded) return;
58
210
  res.write(`event: ${eventType}\n`);
59
211
  res.write(`data: ${JSON.stringify(data)}\n\n`);
60
212
  };
61
-
62
- /**
63
- * Build a lookup map from comparisons array keyed by id
64
- */
65
- const buildComparisonMap = comparisons => {
66
- let map = new Map();
67
- for (let c of comparisons) {
68
- map.set(c.id, c);
69
- }
70
- return map;
71
- };
72
- const comparisonChanged = (oldComp, newComp) => {
73
- return JSON.stringify(oldComp) !== JSON.stringify(newComp);
74
- };
75
-
76
- /**
77
- * Extract summary fields (everything except comparisons) for diffing
78
- */
79
- const extractSummary = data => {
80
- let {
81
- comparisons: _c,
82
- ...summary
83
- } = data;
84
- return summary;
85
- };
86
-
87
- /**
88
- * Check if summary-level fields changed between old and new data
89
- */
90
- const summaryChanged = (oldData, newData) => {
91
- let oldSummary = extractSummary(oldData);
92
- let newSummary = extractSummary(newData);
93
- return JSON.stringify(oldSummary) !== JSON.stringify(newSummary);
94
- };
95
-
96
- /**
97
- * Send incremental updates by diffing old vs new report data.
98
- * Returns true if any events were sent.
99
- */
100
- const sendIncrementalUpdates = (res, oldData, newData) => {
101
- let sent = false;
102
- let oldComparisons = oldData.comparisons || [];
103
- let newComparisons = newData.comparisons || [];
104
- let oldMap = buildComparisonMap(oldComparisons);
105
- let newMap = buildComparisonMap(newComparisons);
106
-
107
- // New or changed comparisons — sends the full comparison object, not a partial delta
108
- for (let [id, newComp] of newMap) {
109
- let oldComp = oldMap.get(id);
110
- if (!oldComp || comparisonChanged(oldComp, newComp)) {
111
- sendEvent(res, 'comparisonUpdate', newComp);
112
- sent = true;
113
- }
114
- }
115
-
116
- // Removed comparisons
117
- for (let [id] of oldMap) {
118
- if (!newMap.has(id)) {
119
- sendEvent(res, 'comparisonRemoved', {
120
- id
121
- });
122
- sent = true;
123
- }
124
- }
125
-
126
- // Summary-level changes (total, passed, failed, etc.)
127
- if (summaryChanged(oldData, newData)) {
128
- sendEvent(res, 'summaryUpdate', extractSummary(newData));
129
- sent = true;
130
- }
131
- return sent;
132
- };
133
213
  return async function handleEventsRoute(req, res, pathname) {
134
214
  if (req.method !== 'GET' || pathname !== '/api/events') {
135
215
  return false;
@@ -144,52 +224,51 @@ export function createEventsRouter(context) {
144
224
  });
145
225
 
146
226
  // Send initial full data immediately
147
- let lastSentData = readReportData();
227
+ let lastSentData = readReportData({
228
+ reportDataPath,
229
+ baselineMetadataPath
230
+ });
148
231
  if (lastSentData) {
149
232
  sendEvent(res, 'reportData', lastSentData);
150
233
  }
151
234
 
152
235
  // Debounce file change events (fs.watch can fire multiple times)
153
236
  let debounceTimer = null;
154
- let watcher = null;
155
- const sendUpdate = () => {
156
- const newData = readReportData();
157
- if (!newData) return;
158
- if (!lastSentData) {
159
- // No previous data — send full payload
160
- sendEvent(res, 'reportData', newData);
161
- } else {
162
- // Diff and send incremental updates
163
- let sent = sendIncrementalUpdates(res, lastSentData, newData);
164
- // If nothing changed, skip (no event needed)
165
- if (!sent) return;
237
+ let scheduleUpdate = () => {
238
+ if (debounceTimer) {
239
+ timers.clearTimeout(debounceTimer);
166
240
  }
167
- lastSentData = newData;
241
+ debounceTimer = timers.setTimeout(sendUpdate, FILE_WATCH_DEBOUNCE_MS);
168
242
  };
169
-
170
- // Watch for file changes
171
- const vizzlyDir = join(workingDir, '.vizzly');
172
- if (existsSync(vizzlyDir)) {
173
- try {
174
- watcher = watch(vizzlyDir, {
175
- recursive: false
176
- }, (_eventType, filename) => {
177
- // Only react to report-data.json changes
178
- if (filename === 'report-data.json') {
179
- // Debounce: wait 100ms after last change before sending
180
- if (debounceTimer) {
181
- clearTimeout(debounceTimer);
182
- }
183
- debounceTimer = setTimeout(sendUpdate, 100);
184
- }
185
- });
186
- } catch {
187
- // File watching not available, client will fall back to polling
243
+ let sendUpdate = (retryCount = 0) => {
244
+ let newData = readReportData({
245
+ reportDataPath,
246
+ baselineMetadataPath
247
+ });
248
+ if (!newData) {
249
+ if (existsSync(reportDataPath) && retryCount < MAX_REPORT_READ_RETRIES) {
250
+ debounceTimer = timers.setTimeout(() => sendUpdate(retryCount + 1), REPORT_READ_RETRY_MS);
251
+ }
252
+ return;
188
253
  }
189
- }
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);
260
+ }
261
+ lastSentData = newData;
262
+ };
263
+ let cleanupReportWatcher = watchReportData({
264
+ workingDir,
265
+ reportDataPath,
266
+ onReportDataChanged: scheduleUpdate,
267
+ timers
268
+ });
190
269
 
191
270
  // Heartbeat to keep connection alive (every 30 seconds)
192
- const heartbeatInterval = setInterval(() => {
271
+ let heartbeatInterval = timers.setInterval(() => {
193
272
  if (!res.writableEnded) {
194
273
  sendEvent(res, 'heartbeat', {
195
274
  timestamp: Date.now()
@@ -198,14 +277,13 @@ export function createEventsRouter(context) {
198
277
  }, 30000);
199
278
 
200
279
  // Cleanup on connection close
201
- const cleanup = () => {
280
+ let cleanup = () => {
202
281
  if (debounceTimer) {
203
- clearTimeout(debounceTimer);
204
- }
205
- clearInterval(heartbeatInterval);
206
- if (watcher) {
207
- watcher.close();
282
+ timers.clearTimeout(debounceTimer);
283
+ debounceTimer = null;
208
284
  }
285
+ timers.clearInterval(heartbeatInterval);
286
+ cleanupReportWatcher();
209
287
  };
210
288
  req.on('close', cleanup);
211
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
  }