@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.
- package/README.md +9 -9
- package/dist/api/client.js +6 -3
- package/dist/cli.js +58 -29
- package/dist/client/index.js +21 -29
- package/dist/commands/api.js +145 -90
- package/dist/commands/baselines.js +78 -47
- package/dist/commands/builds.js +9 -8
- package/dist/commands/comparisons.js +7 -8
- package/dist/commands/config-cmd.js +15 -7
- package/dist/commands/context.js +329 -18
- package/dist/commands/doctor.js +116 -81
- package/dist/commands/init.js +202 -201
- package/dist/commands/login.js +134 -65
- package/dist/commands/logout.js +17 -6
- package/dist/commands/preview.js +29 -57
- package/dist/commands/project.js +120 -0
- package/dist/commands/projects.js +9 -2
- package/dist/commands/review.js +236 -202
- package/dist/commands/run.js +75 -72
- package/dist/commands/status.js +228 -150
- package/dist/commands/tdd-daemon-process.js +98 -0
- package/dist/commands/tdd-daemon.js +318 -212
- package/dist/commands/tdd.js +23 -8
- package/dist/commands/upload.js +10 -9
- package/dist/commands/whoami.js +63 -25
- package/dist/config/core.js +36 -35
- package/dist/context/local-workspace-provider.js +132 -12
- package/dist/context/provider-resolver.js +5 -1
- package/dist/index.js +5 -4
- package/dist/plugin-loader.js +55 -49
- package/dist/project/core.js +2 -2
- package/dist/project/operations.js +6 -3
- package/dist/reporter/reporter-bundle.css +1 -1
- package/dist/reporter/reporter-bundle.iife.js +7 -7
- package/dist/sdk/index.js +162 -107
- package/dist/server/handlers/tdd-handler.js +30 -54
- package/dist/server/routers/baseline.js +195 -178
- package/dist/server/routers/events.js +212 -174
- package/dist/server/routers/projects.js +14 -5
- package/dist/server/routers/screenshot.js +1 -31
- package/dist/services/build-manager.js +19 -97
- package/dist/services/config-service.js +26 -34
- package/dist/services/index.js +2 -4
- package/dist/services/static-report-generator.js +11 -12
- package/dist/services/test-runner.js +21 -27
- package/dist/tdd/index.js +1 -1
- package/dist/tdd/metadata/region-metadata.js +0 -37
- package/dist/tdd/runtime-context.js +78 -0
- package/dist/tdd/server-registry.js +38 -17
- package/dist/tdd/services/baseline-manager.js +23 -0
- package/dist/tdd/services/hotspot-service.js +0 -13
- package/dist/tdd/tdd-service.js +82 -113
- package/dist/types/client.d.ts +36 -1
- package/dist/types/index.d.ts +114 -22
- package/dist/types/sdk.d.ts +40 -0
- package/dist/uploader/core.js +5 -4
- package/dist/uploader/index.js +79 -2
- package/dist/uploader/operations.js +40 -5
- package/dist/utils/api-url.js +14 -0
- package/dist/utils/async-utils.js +26 -0
- package/dist/utils/ci-env.js +19 -12
- package/dist/utils/config-loader.js +39 -65
- package/dist/utils/config-schema.js +19 -55
- package/dist/utils/fetch-utils.js +12 -5
- package/dist/utils/git.js +35 -72
- package/dist/utils/global-config.js +20 -1
- package/dist/utils/output.js +19 -7
- package/dist/utils/patterns.js +14 -0
- package/dist/utils/project-link-store.js +105 -0
- package/dist/utils/screenshot-options.js +28 -0
- package/dist/utils/secret-store.js +65 -0
- package/package.json +11 -8
- package/dist/services/screenshot-server.js +0 -65
- package/dist/services/server-manager.js +0 -81
- package/dist/services/uploader.js +0 -100
- package/dist/tdd/services/region-service.js +0 -75
- 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
|
-
*
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
280
|
+
let cleanup = () => {
|
|
236
281
|
if (debounceTimer) {
|
|
237
|
-
clearTimeout(debounceTimer);
|
|
282
|
+
timers.clearTimeout(debounceTimer);
|
|
238
283
|
debounceTimer = null;
|
|
239
284
|
}
|
|
240
|
-
clearInterval(heartbeatInterval);
|
|
241
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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 -
|
|
2
|
+
* Build Manager - local build object creation for the test runner.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
|
|
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:
|
|
32
|
-
name: name || `build-${
|
|
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:
|
|
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
|
}
|