plum-e2e 2.4.13 → 2.5.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/backend/_scaffold/utils/browser.ts +47 -2
- package/backend/_scaffold/utils/hooks.ts +25 -2
- package/backend/config/scripts/run-tests.js +35 -13
- package/backend/logs/runner-cmr8o2x3n0000mg01410tpuck.log +20 -0
- package/backend/logs/runner-cmr90hvcx0000n2016edtmrtv.log +43 -0
- package/backend/logs/runner-cmr90i7h20001n201g2wg6v3n.log +43 -0
- package/backend/logs/runner-cmr90iilm0002n201mmisva95.log +43 -0
- package/backend/logs/runner-cmr90j70r0003n201xz8525dz.log +20 -0
- package/backend/logs/runner-cmr90yup70000qp017ianc2gp.log +43 -0
- package/backend/logs/runner-cmr90zbtm0001qp01u9tfdkh1.log +20 -0
- package/backend/logs/runner-cmr91a3mw0000tb01414rd1df.log +20 -0
- package/backend/logs/runner-cmr91dhla0001tb01jp7thjnl.log +20 -0
- package/backend/prisma/migrations/20260706000000_add_report_logs/migration.sql +2 -0
- package/backend/prisma/schema.prisma +1 -0
- package/backend/routes/node.routes.js +42 -4
- package/backend/services/reportService.js +24 -4
- package/backend/services/runnerService.js +11 -1
- package/backend/websockets/socketHandler.js +162 -16
- package/frontend/package.json +1 -1
- package/frontend/src/lib/components/layout/RunnerPanel.svelte +53 -55
- package/frontend/src/lib/components/ui/AutomatedBadge.svelte +56 -0
- package/frontend/src/lib/components/ui/BackLink.svelte +77 -0
- package/frontend/src/lib/components/ui/Badge.svelte +1 -1
- package/frontend/src/lib/components/ui/Button.svelte +1 -1
- package/frontend/src/lib/components/ui/CaseIdChip.svelte +59 -0
- package/frontend/src/lib/components/ui/ConfirmModal.svelte +1 -1
- package/frontend/src/lib/components/ui/Pagination.svelte +1 -1
- package/frontend/src/lib/components/ui/PriorityBadge.svelte +83 -0
- package/frontend/src/lib/components/ui/ResultChip.svelte +92 -0
- package/frontend/src/lib/components/ui/StatusDot.svelte +64 -0
- package/frontend/src/lib/components/ui/StepKeyword.svelte +79 -0
- package/frontend/src/lib/components/ui/StepStatusIcon.svelte +66 -0
- package/frontend/src/lib/components/ui/TagChip.svelte +51 -0
- package/frontend/src/lib/constants.js +14 -0
- package/frontend/src/lib/stores/runner.js +5 -3
- package/frontend/src/lib/styles/tokens.css +26 -0
- package/frontend/src/lib/utils/format.js +74 -0
- package/frontend/src/routes/+page.svelte +3 -3
- package/frontend/src/routes/login/+page.svelte +1 -1
- package/frontend/src/routes/reports/+page.svelte +3 -3
- package/frontend/src/routes/reports/[id]/+page.svelte +703 -215
- package/frontend/src/routes/reports/live/+page.svelte +281 -283
- package/frontend/src/routes/scheduled-tests/+page.svelte +3 -3
- package/frontend/src/routes/settings/+page.svelte +6 -6
- package/frontend/src/routes/setup/+page.svelte +1 -1
- package/frontend/src/routes/test-repository/+page.svelte +5 -5
- package/frontend/src/routes/test-repository/runs/[id]/+page.svelte +27 -151
- package/frontend/src/routes/test-repository/suites/[id]/+page.svelte +4 -4
- package/package.json +1 -1
|
@@ -138,7 +138,13 @@ async function fetchReportContent(runner, jobId, onLog) {
|
|
|
138
138
|
* @param {(log: string) => void} onLog Called with each new log chunk
|
|
139
139
|
* @param {(exitCode: number, reportContent: string|null) => void} onDone
|
|
140
140
|
*/
|
|
141
|
-
async function dispatchAndPoll(
|
|
141
|
+
async function dispatchAndPoll(
|
|
142
|
+
runnerId,
|
|
143
|
+
{ tags, browser, workers },
|
|
144
|
+
onLog,
|
|
145
|
+
onDone,
|
|
146
|
+
onScreenshot = null
|
|
147
|
+
) {
|
|
142
148
|
// The async poll callback can overlap if a tick takes longer than the interval;
|
|
143
149
|
// guard so the run resolves exactly once and can't be finalised while a lane
|
|
144
150
|
// is still in flight.
|
|
@@ -195,6 +201,10 @@ async function dispatchAndPoll(runnerId, { tags, browser, workers }, onLog, onDo
|
|
|
195
201
|
logOffset += body.logs.length;
|
|
196
202
|
}
|
|
197
203
|
|
|
204
|
+
if (onScreenshot && Array.isArray(body.screenshots)) {
|
|
205
|
+
for (const ss of body.screenshots) onScreenshot(ss);
|
|
206
|
+
}
|
|
207
|
+
|
|
198
208
|
if (body.status === 'done' || body.status === 'error') {
|
|
199
209
|
clearInterval(poll);
|
|
200
210
|
const content = await fetchReportContent(runner, jobId, onLog);
|
|
@@ -16,12 +16,16 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
const { spawn } = require('child_process');
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const os = require('os');
|
|
21
|
+
const path = require('path');
|
|
19
22
|
const runnerService = require('../services/runnerService');
|
|
20
23
|
const reportService = require('../services/reportService');
|
|
21
24
|
const notificationService = require('../services/notificationService');
|
|
22
25
|
const { TRIGGER_TYPE, BUILT_IN_RUNNER_ID, TRIGGER_REMOTE } = require('../constants/triggers');
|
|
23
26
|
const { getTestIdsForTag, chunkTests, buildTagExpression } = require('../lib/testChunker');
|
|
24
27
|
const { readCucumberReportFile } = require('../lib/reportFilename');
|
|
28
|
+
const { getTestSuites } = require('../services/testService');
|
|
25
29
|
const prisma = require('../services/prisma');
|
|
26
30
|
|
|
27
31
|
const socketHandler = (io) => {
|
|
@@ -110,10 +114,80 @@ const socketHandler = (io) => {
|
|
|
110
114
|
});
|
|
111
115
|
};
|
|
112
116
|
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Helpers
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
function makeSyntheticFailReport(laneName, testIds, reason) {
|
|
122
|
+
// Build id → scenario title from local feature files so names match the real report.
|
|
123
|
+
const nameMap = {};
|
|
124
|
+
try {
|
|
125
|
+
const { suites } = getTestSuites();
|
|
126
|
+
for (const suite of suites) {
|
|
127
|
+
for (const test of suite.tests) {
|
|
128
|
+
for (const id of Array.isArray(test.id) ? test.id : [test.id]) {
|
|
129
|
+
nameMap[id] = test.testCase;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
} catch {}
|
|
134
|
+
|
|
135
|
+
return JSON.stringify([
|
|
136
|
+
{
|
|
137
|
+
id: 'runner-error',
|
|
138
|
+
uri: 'runner-error',
|
|
139
|
+
name: `Runner: ${laneName}`,
|
|
140
|
+
keyword: 'Feature',
|
|
141
|
+
elements: testIds.map((id) => ({
|
|
142
|
+
id: id.replace(/^@/, '').toLowerCase(),
|
|
143
|
+
// Use real scenario title; fall back to bare ID without @
|
|
144
|
+
name: nameMap[id] || id.replace(/^@/, ''),
|
|
145
|
+
keyword: 'Scenario',
|
|
146
|
+
type: 'scenario',
|
|
147
|
+
// ids from getTestIdsForTag already carry the @ — don't add a second one
|
|
148
|
+
tags: [{ name: id.startsWith('@') ? id : `@${id}` }],
|
|
149
|
+
steps: [
|
|
150
|
+
{
|
|
151
|
+
keyword: 'Given ',
|
|
152
|
+
name: 'the scenario was assigned to this runner',
|
|
153
|
+
result: {
|
|
154
|
+
status: 'failed',
|
|
155
|
+
error_message: `Runner "${laneName}" did not complete: ${reason}`,
|
|
156
|
+
duration: 0
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
]
|
|
160
|
+
}))
|
|
161
|
+
}
|
|
162
|
+
]);
|
|
163
|
+
}
|
|
164
|
+
|
|
113
165
|
// ---------------------------------------------------------------------------
|
|
114
166
|
// Single built-in runner
|
|
115
167
|
// ---------------------------------------------------------------------------
|
|
116
168
|
|
|
169
|
+
function startSsPoller(ssDir, onScreenshot) {
|
|
170
|
+
const seenFiles = new Set();
|
|
171
|
+
return setInterval(() => {
|
|
172
|
+
try {
|
|
173
|
+
const files = fs
|
|
174
|
+
.readdirSync(ssDir)
|
|
175
|
+
.filter((f) => f.endsWith('.ss.json'))
|
|
176
|
+
.sort();
|
|
177
|
+
for (const f of files) {
|
|
178
|
+
if (seenFiles.has(f)) continue;
|
|
179
|
+
seenFiles.add(f);
|
|
180
|
+
const filePath = path.join(ssDir, f);
|
|
181
|
+
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
182
|
+
onScreenshot(data);
|
|
183
|
+
try {
|
|
184
|
+
fs.unlinkSync(filePath);
|
|
185
|
+
} catch {}
|
|
186
|
+
}
|
|
187
|
+
} catch {}
|
|
188
|
+
}, 400);
|
|
189
|
+
}
|
|
190
|
+
|
|
117
191
|
function runBuiltIn(
|
|
118
192
|
io,
|
|
119
193
|
socket,
|
|
@@ -125,12 +199,16 @@ function runBuiltIn(
|
|
|
125
199
|
notifyDiscord,
|
|
126
200
|
notifySlack
|
|
127
201
|
) {
|
|
202
|
+
const ssDir = path.join(os.tmpdir(), `plum-ss-${Date.now()}`);
|
|
203
|
+
fs.mkdirSync(ssDir, { recursive: true });
|
|
204
|
+
|
|
128
205
|
const env = {
|
|
129
206
|
...process.env,
|
|
130
207
|
TAG: tag,
|
|
131
208
|
TRIGGER: TRIGGER_TYPE.MANUAL,
|
|
132
209
|
REPORT_RUNNERS: String(workers),
|
|
133
|
-
BROWSER: browser
|
|
210
|
+
BROWSER: browser,
|
|
211
|
+
PLUM_SS_DIR: ssDir
|
|
134
212
|
};
|
|
135
213
|
if (workers > 1) env.PARALLEL = String(workers);
|
|
136
214
|
if (testRunId) env.TEST_RUN_ID = testRunId;
|
|
@@ -138,15 +216,47 @@ function runBuiltIn(
|
|
|
138
216
|
const proc = spawn('npm', ['run', 'test'], { env, shell: true });
|
|
139
217
|
activeProcs.add(proc);
|
|
140
218
|
|
|
141
|
-
|
|
142
|
-
|
|
219
|
+
const ssPoller = startSsPoller(ssDir, ({ stepName, data }) => {
|
|
220
|
+
socket.emit('step-screenshot', { stepName, data });
|
|
221
|
+
});
|
|
143
222
|
|
|
144
|
-
|
|
223
|
+
let logBuffer = '';
|
|
224
|
+
proc.stdout.on('data', (d) => {
|
|
225
|
+
const text = d.toString();
|
|
226
|
+
logBuffer += text;
|
|
227
|
+
socket.emit('log', text);
|
|
228
|
+
});
|
|
229
|
+
proc.stderr.on('data', (d) => {
|
|
230
|
+
const text = `[ERROR] ${d.toString()}`;
|
|
231
|
+
logBuffer += text;
|
|
232
|
+
socket.emit('log', text);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
proc.on('close', async (code) => {
|
|
236
|
+
clearInterval(ssPoller);
|
|
237
|
+
fs.rm(ssDir, { recursive: true, force: true }, () => {});
|
|
145
238
|
activeProcs.delete(proc);
|
|
146
239
|
socket.emit('log', `\nTest finished with code ${code}`);
|
|
147
240
|
socket.emit('done', code);
|
|
148
241
|
io.emit('report-ready');
|
|
149
242
|
|
|
243
|
+
// Attach accumulated logs to the report generate-report.js just saved
|
|
244
|
+
try {
|
|
245
|
+
const latest = await prisma.report.findFirst({
|
|
246
|
+
where: { triggerType: TRIGGER_TYPE.MANUAL },
|
|
247
|
+
orderBy: { createdAt: 'desc' },
|
|
248
|
+
select: { id: true }
|
|
249
|
+
});
|
|
250
|
+
if (latest) {
|
|
251
|
+
await prisma.report.update({
|
|
252
|
+
where: { id: latest.id },
|
|
253
|
+
data: { logs: logBuffer || null }
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
} catch (e) {
|
|
257
|
+
console.error('[socket] Failed to save run logs:', e.message);
|
|
258
|
+
}
|
|
259
|
+
|
|
150
260
|
if (notifyDiscord || notifySlack) {
|
|
151
261
|
prisma.report
|
|
152
262
|
.findFirst({
|
|
@@ -224,6 +334,8 @@ async function runDistributed(
|
|
|
224
334
|
|
|
225
335
|
const total = activeRunnerIds.length;
|
|
226
336
|
const collectedReports = new Array(total).fill(null);
|
|
337
|
+
const laneLogs = {};
|
|
338
|
+
for (const l of laneInfos) laneLogs[l.id] = '';
|
|
227
339
|
let doneCount = 0;
|
|
228
340
|
let overallCode = 0;
|
|
229
341
|
|
|
@@ -244,7 +356,8 @@ async function runDistributed(
|
|
|
244
356
|
tag,
|
|
245
357
|
triggerType: TRIGGER_TYPE.MANUAL,
|
|
246
358
|
browser,
|
|
247
|
-
testRunId: testRunId ?? null
|
|
359
|
+
testRunId: testRunId ?? null,
|
|
360
|
+
laneLogs
|
|
248
361
|
})
|
|
249
362
|
.then((saved) => {
|
|
250
363
|
// Result is authoritative from the merged report, not the exit code —
|
|
@@ -278,40 +391,73 @@ async function runDistributed(
|
|
|
278
391
|
for (let i = 0; i < activeRunnerIds.length; i++) {
|
|
279
392
|
const lane = laneInfos[i];
|
|
280
393
|
const chunkTag = buildTagExpression(chunks[i]);
|
|
394
|
+
const chunkIds = chunks[i];
|
|
281
395
|
|
|
282
396
|
if (lane.id === BUILT_IN_RUNNER_ID) {
|
|
397
|
+
const laneId = lane.id;
|
|
398
|
+
const ssDir = path.join(os.tmpdir(), `plum-ss-${Date.now()}-${i}`);
|
|
399
|
+
fs.mkdirSync(ssDir, { recursive: true });
|
|
400
|
+
|
|
283
401
|
const env = {
|
|
284
402
|
...process.env,
|
|
285
403
|
TAG: chunkTag,
|
|
286
404
|
TRIGGER: TRIGGER_REMOTE,
|
|
287
405
|
BROWSER: browser,
|
|
288
406
|
REPORT_RUNNERS: String(workers),
|
|
289
|
-
PLUM_MODE: 'node'
|
|
407
|
+
PLUM_MODE: 'node',
|
|
408
|
+
PLUM_SS_DIR: ssDir
|
|
290
409
|
};
|
|
291
410
|
if (workers > 1) env.PARALLEL = String(workers);
|
|
292
411
|
|
|
293
412
|
const proc = spawn('npm', ['run', 'test'], { env, shell: true });
|
|
294
413
|
activeProcs.add(proc);
|
|
295
414
|
|
|
296
|
-
|
|
297
|
-
socket.emit('runner-lane-
|
|
298
|
-
);
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
415
|
+
const ssPoller = startSsPoller(ssDir, ({ stepName, data }) => {
|
|
416
|
+
socket.emit('runner-lane-screenshot', { id: laneId, stepName, data });
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
proc.stdout.on('data', (d) => {
|
|
420
|
+
const text = d.toString();
|
|
421
|
+
laneLogs[laneId] += text;
|
|
422
|
+
socket.emit('runner-lane-log', { id: laneId, log: text });
|
|
423
|
+
});
|
|
424
|
+
proc.stderr.on('data', (d) => {
|
|
425
|
+
const text = `[ERROR] ${d.toString()}`;
|
|
426
|
+
laneLogs[laneId] += text;
|
|
427
|
+
socket.emit('runner-lane-log', { id: laneId, log: text });
|
|
428
|
+
});
|
|
302
429
|
|
|
303
430
|
const idx = i;
|
|
304
431
|
proc.on('close', (code) => {
|
|
432
|
+
clearInterval(ssPoller);
|
|
433
|
+
fs.rm(ssDir, { recursive: true, force: true }, () => {});
|
|
305
434
|
activeProcs.delete(proc);
|
|
306
|
-
|
|
435
|
+
const content =
|
|
436
|
+
readCucumberReportFile() ??
|
|
437
|
+
makeSyntheticFailReport(lane.name, chunkIds, 'process exited with error');
|
|
438
|
+
onLaneDone(idx, laneId, code, content);
|
|
307
439
|
});
|
|
308
440
|
} else {
|
|
309
441
|
const idx = i;
|
|
442
|
+
const laneId = lane.id;
|
|
310
443
|
runnerService.dispatchAndPoll(
|
|
311
|
-
|
|
444
|
+
laneId,
|
|
312
445
|
{ tags: chunkTag, browser, workers },
|
|
313
|
-
(log) =>
|
|
314
|
-
|
|
446
|
+
(log) => {
|
|
447
|
+
laneLogs[laneId] += log;
|
|
448
|
+
socket.emit('runner-lane-log', { id: laneId, log });
|
|
449
|
+
},
|
|
450
|
+
(code, content) =>
|
|
451
|
+
onLaneDone(
|
|
452
|
+
idx,
|
|
453
|
+
laneId,
|
|
454
|
+
code,
|
|
455
|
+
content ??
|
|
456
|
+
makeSyntheticFailReport(lane.name, chunkIds, 'could not fetch report from runner')
|
|
457
|
+
),
|
|
458
|
+
({ stepName, data }) => {
|
|
459
|
+
socket.emit('runner-lane-screenshot', { id: laneId, stepName, data });
|
|
460
|
+
}
|
|
315
461
|
);
|
|
316
462
|
}
|
|
317
463
|
}
|
package/frontend/package.json
CHANGED
|
@@ -35,8 +35,19 @@
|
|
|
35
35
|
import { fetchRunners } from '$lib/api/runners';
|
|
36
36
|
import { fetchRuns, fetchRun } from '$lib/api/repository';
|
|
37
37
|
import { fetchIntegrations } from '$lib/api/settings';
|
|
38
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
API_BASE,
|
|
40
|
+
BROWSERS,
|
|
41
|
+
BUILTIN_RUNNER_ID,
|
|
42
|
+
BUILTIN_RUNNER_LABEL,
|
|
43
|
+
WORKERS_MIN,
|
|
44
|
+
WORKERS_MAX,
|
|
45
|
+
RUN_PICKER_LIMIT,
|
|
46
|
+
RUN_TAG_DISPLAY_LIMIT,
|
|
47
|
+
ALL_TESTS_LABEL
|
|
48
|
+
} from '$lib/constants';
|
|
39
49
|
import ConfirmModal from '$lib/components/ui/ConfirmModal.svelte';
|
|
50
|
+
import Badge from '$lib/components/ui/Badge.svelte';
|
|
40
51
|
|
|
41
52
|
let availableRunners = [];
|
|
42
53
|
let testRuns = [];
|
|
@@ -83,10 +94,10 @@
|
|
|
83
94
|
availableRunners = r;
|
|
84
95
|
// Drop any saved selection pointing at runners that no longer exist,
|
|
85
96
|
// so a deleted runner can't linger in the selection and break runs.
|
|
86
|
-
const validIds = new Set([
|
|
97
|
+
const validIds = new Set([BUILTIN_RUNNER_ID, ...r.map((x) => x.id)]);
|
|
87
98
|
runnerConfig.update((c) => {
|
|
88
99
|
const pruned = c.selectedRunners.filter((id) => validIds.has(id));
|
|
89
|
-
return { ...c, selectedRunners: pruned.length > 0 ? pruned : [
|
|
100
|
+
return { ...c, selectedRunners: pruned.length > 0 ? pruned : [BUILTIN_RUNNER_ID] };
|
|
90
101
|
});
|
|
91
102
|
})
|
|
92
103
|
.catch(() => {});
|
|
@@ -110,8 +121,8 @@
|
|
|
110
121
|
localStorage.setItem('plum:builtInEnabled', String(v));
|
|
111
122
|
} catch {}
|
|
112
123
|
runnerConfig.update((c) => {
|
|
113
|
-
if (!v && c.selectedRunners.includes(
|
|
114
|
-
const others = c.selectedRunners.filter((r) => r !==
|
|
124
|
+
if (!v && c.selectedRunners.includes(BUILTIN_RUNNER_ID)) {
|
|
125
|
+
const others = c.selectedRunners.filter((r) => r !== BUILTIN_RUNNER_ID);
|
|
115
126
|
return { ...c, selectedRunners: others.length > 0 ? others : c.selectedRunners };
|
|
116
127
|
}
|
|
117
128
|
return c;
|
|
@@ -155,7 +166,7 @@
|
|
|
155
166
|
s.on('runner-lanes-init', (lanes) => {
|
|
156
167
|
runnerState.update((r) => ({
|
|
157
168
|
...r,
|
|
158
|
-
lanes: lanes.map((l) => ({ ...l, status: 'running', logs: '' }))
|
|
169
|
+
lanes: lanes.map((l) => ({ ...l, status: 'running', logs: '', latestScreenshot: null }))
|
|
159
170
|
}));
|
|
160
171
|
});
|
|
161
172
|
|
|
@@ -173,6 +184,19 @@
|
|
|
173
184
|
}));
|
|
174
185
|
});
|
|
175
186
|
|
|
187
|
+
s.on('step-screenshot', ({ stepName, data }) => {
|
|
188
|
+
runnerState.update((r) => ({ ...r, latestScreenshot: { stepName, data } }));
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
s.on('runner-lane-screenshot', ({ id, stepName, data }) => {
|
|
192
|
+
runnerState.update((r) => ({
|
|
193
|
+
...r,
|
|
194
|
+
lanes: r.lanes.map((l) =>
|
|
195
|
+
l.id === id ? { ...l, latestScreenshot: { stepName, data } } : l
|
|
196
|
+
)
|
|
197
|
+
}));
|
|
198
|
+
});
|
|
199
|
+
|
|
176
200
|
s.on('tests-changed', () => testsVersion.update((v) => v + 1));
|
|
177
201
|
s.on('report-ready', () => reportsVersion.update((v) => v + 1));
|
|
178
202
|
|
|
@@ -200,17 +224,20 @@
|
|
|
200
224
|
$: cfg = $runnerConfig;
|
|
201
225
|
|
|
202
226
|
$: truncatedRunTag = (() => {
|
|
203
|
-
if (!state.currentRun?.tag) return
|
|
227
|
+
if (!state.currentRun?.tag) return ALL_TESTS_LABEL;
|
|
204
228
|
const parts = state.currentRun.tag.split(/ or /i);
|
|
205
|
-
if (parts.length <=
|
|
206
|
-
return
|
|
229
|
+
if (parts.length <= RUN_TAG_DISPLAY_LIMIT) return state.currentRun.tag;
|
|
230
|
+
return (
|
|
231
|
+
parts.slice(0, RUN_TAG_DISPLAY_LIMIT).join(' or ') +
|
|
232
|
+
` +${parts.length - RUN_TAG_DISPLAY_LIMIT} more`
|
|
233
|
+
);
|
|
207
234
|
})();
|
|
208
235
|
$: cronJobs = Object.keys($activeCronJobs);
|
|
209
236
|
$: anyCronRunning = cronJobs.length > 0;
|
|
210
237
|
$: anyRunning = state.running || anyCronRunning;
|
|
211
238
|
|
|
212
239
|
$: if ($runsVersion >= 0)
|
|
213
|
-
fetchRuns({ limit:
|
|
240
|
+
fetchRuns({ limit: RUN_PICKER_LIMIT })
|
|
214
241
|
.then((r) => (testRuns = r.runs))
|
|
215
242
|
.catch(() => {});
|
|
216
243
|
|
|
@@ -238,8 +265,8 @@
|
|
|
238
265
|
$: currentBrowser = BROWSERS.find((b) => b.id === cfg.browser) ?? BROWSERS[0];
|
|
239
266
|
|
|
240
267
|
$: runnerSummary =
|
|
241
|
-
cfg.selectedRunners.length === 1 && cfg.selectedRunners[0] ===
|
|
242
|
-
?
|
|
268
|
+
cfg.selectedRunners.length === 1 && cfg.selectedRunners[0] === BUILTIN_RUNNER_ID
|
|
269
|
+
? BUILTIN_RUNNER_LABEL
|
|
243
270
|
: cfg.selectedRunners.length === 1
|
|
244
271
|
? (availableRunners.find((r) => r.id === cfg.selectedRunners[0])?.name ?? '1 node')
|
|
245
272
|
: `${cfg.selectedRunners.length} nodes`;
|
|
@@ -285,7 +312,7 @@
|
|
|
285
312
|
function adjustWorkers(delta) {
|
|
286
313
|
runnerConfig.update((c) => ({
|
|
287
314
|
...c,
|
|
288
|
-
workers: Math.max(
|
|
315
|
+
workers: Math.max(WORKERS_MIN, Math.min(WORKERS_MAX, c.workers + delta))
|
|
289
316
|
}));
|
|
290
317
|
}
|
|
291
318
|
|
|
@@ -483,13 +510,13 @@
|
|
|
483
510
|
<button
|
|
484
511
|
class="step-btn"
|
|
485
512
|
on:click={() => adjustWorkers(-1)}
|
|
486
|
-
disabled={cfg.workers <=
|
|
513
|
+
disabled={cfg.workers <= WORKERS_MIN || state.running}>−</button
|
|
487
514
|
>
|
|
488
515
|
<span class="step-val">{cfg.workers}</span>
|
|
489
516
|
<button
|
|
490
517
|
class="step-btn"
|
|
491
518
|
on:click={() => adjustWorkers(1)}
|
|
492
|
-
disabled={cfg.workers >=
|
|
519
|
+
disabled={cfg.workers >= WORKERS_MAX || state.running}>+</button
|
|
493
520
|
>
|
|
494
521
|
</div>
|
|
495
522
|
</div>
|
|
@@ -551,7 +578,7 @@
|
|
|
551
578
|
<button
|
|
552
579
|
class="dropdown-trigger"
|
|
553
580
|
class:open={runnersOpen}
|
|
554
|
-
class:has-remote={cfg.selectedRunners.some((r) => r !==
|
|
581
|
+
class:has-remote={cfg.selectedRunners.some((r) => r !== BUILTIN_RUNNER_ID)}
|
|
555
582
|
on:click={() => {
|
|
556
583
|
if (!state.running) runnersOpen = !runnersOpen;
|
|
557
584
|
}}
|
|
@@ -578,11 +605,11 @@
|
|
|
578
605
|
<label class="runner-option">
|
|
579
606
|
<input
|
|
580
607
|
type="checkbox"
|
|
581
|
-
checked={isRunnerSelected(
|
|
582
|
-
on:change={() => toggleRunner(
|
|
608
|
+
checked={isRunnerSelected(BUILTIN_RUNNER_ID)}
|
|
609
|
+
on:change={() => toggleRunner(BUILTIN_RUNNER_ID)}
|
|
583
610
|
/>
|
|
584
611
|
<span class="runner-dot built-in"></span>
|
|
585
|
-
<span>
|
|
612
|
+
<span>{BUILTIN_RUNNER_LABEL}</span>
|
|
586
613
|
</label>
|
|
587
614
|
{/if}
|
|
588
615
|
{#each availableRunners as r}
|
|
@@ -704,7 +731,7 @@
|
|
|
704
731
|
</span>
|
|
705
732
|
{/if}
|
|
706
733
|
</div>
|
|
707
|
-
<
|
|
734
|
+
<Badge variant="tag">Live</Badge>
|
|
708
735
|
<svg
|
|
709
736
|
width="13"
|
|
710
737
|
height="13"
|
|
@@ -727,7 +754,7 @@
|
|
|
727
754
|
<span class="run-card-label">{name}</span>
|
|
728
755
|
<span class="run-card-meta">Scheduled run</span>
|
|
729
756
|
</div>
|
|
730
|
-
<
|
|
757
|
+
<Badge variant="schedule">Scheduled</Badge>
|
|
731
758
|
<svg
|
|
732
759
|
width="13"
|
|
733
760
|
height="13"
|
|
@@ -860,18 +887,6 @@
|
|
|
860
887
|
animation: dotPulse 1.6s ease-in-out infinite;
|
|
861
888
|
}
|
|
862
889
|
|
|
863
|
-
@keyframes dotPulse {
|
|
864
|
-
0%,
|
|
865
|
-
100% {
|
|
866
|
-
opacity: 1;
|
|
867
|
-
transform: scale(1);
|
|
868
|
-
}
|
|
869
|
-
50% {
|
|
870
|
-
opacity: 0.4;
|
|
871
|
-
transform: scale(0.65);
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
|
|
875
890
|
.status-word {
|
|
876
891
|
font-size: 0.72rem;
|
|
877
892
|
font-weight: 600;
|
|
@@ -887,7 +902,7 @@
|
|
|
887
902
|
color: var(--text-muted);
|
|
888
903
|
background: var(--bg-subtle);
|
|
889
904
|
border: 1px solid var(--border);
|
|
890
|
-
border-radius:
|
|
905
|
+
border-radius: var(--radius-pill);
|
|
891
906
|
padding: 0.1rem 0.45rem;
|
|
892
907
|
white-space: nowrap;
|
|
893
908
|
overflow: hidden;
|
|
@@ -915,7 +930,7 @@
|
|
|
915
930
|
|
|
916
931
|
.view-report-btn:hover {
|
|
917
932
|
background: var(--accent);
|
|
918
|
-
color:
|
|
933
|
+
color: var(--white);
|
|
919
934
|
}
|
|
920
935
|
|
|
921
936
|
/* ── Center: controls ── */
|
|
@@ -1175,7 +1190,7 @@
|
|
|
1175
1190
|
height: 30px;
|
|
1176
1191
|
padding: 0 0.875rem;
|
|
1177
1192
|
background: var(--accent);
|
|
1178
|
-
color: white;
|
|
1193
|
+
color: var(--white);
|
|
1179
1194
|
border: none;
|
|
1180
1195
|
border-radius: var(--radius-sm);
|
|
1181
1196
|
font-family: var(--font-body);
|
|
@@ -1199,7 +1214,7 @@
|
|
|
1199
1214
|
width: 10px;
|
|
1200
1215
|
height: 10px;
|
|
1201
1216
|
border: 1.5px solid rgba(255, 255, 255, 0.35);
|
|
1202
|
-
border-top-color: white;
|
|
1217
|
+
border-top-color: var(--white);
|
|
1203
1218
|
border-radius: 50%;
|
|
1204
1219
|
animation: spin 0.65s linear infinite;
|
|
1205
1220
|
flex-shrink: 0;
|
|
@@ -1241,7 +1256,7 @@
|
|
|
1241
1256
|
.notify-btn.active {
|
|
1242
1257
|
background: var(--accent);
|
|
1243
1258
|
border-color: var(--accent);
|
|
1244
|
-
color:
|
|
1259
|
+
color: var(--white);
|
|
1245
1260
|
}
|
|
1246
1261
|
|
|
1247
1262
|
.notify-btn:disabled {
|
|
@@ -1363,23 +1378,6 @@
|
|
|
1363
1378
|
margin: 0 0.15rem;
|
|
1364
1379
|
}
|
|
1365
1380
|
|
|
1366
|
-
.run-card-badge {
|
|
1367
|
-
font-size: 0.62rem;
|
|
1368
|
-
font-weight: 600;
|
|
1369
|
-
letter-spacing: 0.07em;
|
|
1370
|
-
text-transform: uppercase;
|
|
1371
|
-
color: var(--accent);
|
|
1372
|
-
background: var(--accent-soft);
|
|
1373
|
-
padding: 0.1rem 0.4rem;
|
|
1374
|
-
border-radius: 100px;
|
|
1375
|
-
flex-shrink: 0;
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1378
|
-
.run-card-badge.cron-badge {
|
|
1379
|
-
color: var(--warn);
|
|
1380
|
-
background: var(--warn-soft);
|
|
1381
|
-
}
|
|
1382
|
-
|
|
1383
1381
|
.run-card-arrow {
|
|
1384
1382
|
color: var(--text-muted);
|
|
1385
1383
|
flex-shrink: 0;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
* This file is part of Plum.
|
|
3
|
+
*
|
|
4
|
+
* Plum is free software: you can redistribute it and/or modify
|
|
5
|
+
* it under the terms of the GNU General Public License as published by
|
|
6
|
+
* the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
* (at your option) any later version.
|
|
8
|
+
*
|
|
9
|
+
* Plum is distributed in the hope that it will be useful,
|
|
10
|
+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
* GNU General Public License for more details.
|
|
13
|
+
*
|
|
14
|
+
* You should have received a copy of the GNU General Public License
|
|
15
|
+
* along with Plum. If not, see https://www.gnu.org/licenses/.
|
|
16
|
+
-->
|
|
17
|
+
|
|
18
|
+
<!--
|
|
19
|
+
* This file is part of Plum.
|
|
20
|
+
*
|
|
21
|
+
* Plum is free software: you can redistribute it and/or modify
|
|
22
|
+
* it under the terms of the GNU General Public License as published by
|
|
23
|
+
* the Free Software Foundation, either version 3 of the License, or
|
|
24
|
+
* (at your option) any later version.
|
|
25
|
+
*
|
|
26
|
+
* Plum is distributed in the hope that it will be useful,
|
|
27
|
+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
28
|
+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
29
|
+
* GNU General Public License for more details.
|
|
30
|
+
*
|
|
31
|
+
* You should have received a copy of the GNU General Public License
|
|
32
|
+
* along with Plum. If not, see https://www.gnu.org/licenses/.
|
|
33
|
+
-->
|
|
34
|
+
|
|
35
|
+
<script>
|
|
36
|
+
import { AUTOMATED_LABEL } from '$lib/constants';
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<span class="auto-badge">{AUTOMATED_LABEL}</span>
|
|
40
|
+
|
|
41
|
+
<style>
|
|
42
|
+
.auto-badge {
|
|
43
|
+
display: inline-flex;
|
|
44
|
+
align-items: center;
|
|
45
|
+
font-size: 0.62rem;
|
|
46
|
+
font-weight: 600;
|
|
47
|
+
text-transform: uppercase;
|
|
48
|
+
letter-spacing: 0.05em;
|
|
49
|
+
color: var(--accent);
|
|
50
|
+
background: var(--accent-soft);
|
|
51
|
+
border-radius: var(--radius-pill);
|
|
52
|
+
padding: 0.1rem 0.45rem;
|
|
53
|
+
flex-shrink: 0;
|
|
54
|
+
white-space: nowrap;
|
|
55
|
+
}
|
|
56
|
+
</style>
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
* This file is part of Plum.
|
|
3
|
+
*
|
|
4
|
+
* Plum is free software: you can redistribute it and/or modify
|
|
5
|
+
* it under the terms of the GNU General Public License as published by
|
|
6
|
+
* the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
* (at your option) any later version.
|
|
8
|
+
*
|
|
9
|
+
* Plum is distributed in the hope that it will be useful,
|
|
10
|
+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
* GNU General Public License for more details.
|
|
13
|
+
*
|
|
14
|
+
* You should have received a copy of the GNU General Public License
|
|
15
|
+
* along with Plum. If not, see https://www.gnu.org/licenses/.
|
|
16
|
+
-->
|
|
17
|
+
|
|
18
|
+
<!--
|
|
19
|
+
* This file is part of Plum.
|
|
20
|
+
*
|
|
21
|
+
* Plum is free software: you can redistribute it and/or modify
|
|
22
|
+
* it under the terms of the GNU General Public License as published by
|
|
23
|
+
* the Free Software Foundation, either version 3 of the License, or
|
|
24
|
+
* (at your option) any later version.
|
|
25
|
+
*
|
|
26
|
+
* Plum is distributed in the hope that it will be useful,
|
|
27
|
+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
28
|
+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
29
|
+
* GNU General Public License for more details.
|
|
30
|
+
*
|
|
31
|
+
* You should have received a copy of the GNU General Public License
|
|
32
|
+
* along with Plum. If not, see https://www.gnu.org/licenses/.
|
|
33
|
+
-->
|
|
34
|
+
|
|
35
|
+
<script>
|
|
36
|
+
export let href = '/reports';
|
|
37
|
+
export let label = 'Reports';
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<div class="back-row">
|
|
41
|
+
<a {href} class="back-link">
|
|
42
|
+
<svg
|
|
43
|
+
width="14"
|
|
44
|
+
height="14"
|
|
45
|
+
viewBox="0 0 24 24"
|
|
46
|
+
fill="none"
|
|
47
|
+
stroke="currentColor"
|
|
48
|
+
stroke-width="2"
|
|
49
|
+
stroke-linecap="round"
|
|
50
|
+
stroke-linejoin="round"
|
|
51
|
+
>
|
|
52
|
+
<line x1="19" y1="12" x2="5" y2="12" />
|
|
53
|
+
<polyline points="12 19 5 12 12 5" />
|
|
54
|
+
</svg>
|
|
55
|
+
{label}
|
|
56
|
+
</a>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<style>
|
|
60
|
+
.back-row {
|
|
61
|
+
margin-bottom: 1.5rem;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.back-link {
|
|
65
|
+
display: inline-flex;
|
|
66
|
+
align-items: center;
|
|
67
|
+
gap: 0.35rem;
|
|
68
|
+
font-size: 0.8125rem;
|
|
69
|
+
color: var(--text-muted);
|
|
70
|
+
text-decoration: none;
|
|
71
|
+
transition: color var(--duration-fast);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.back-link:hover {
|
|
75
|
+
color: var(--text);
|
|
76
|
+
}
|
|
77
|
+
</style>
|