plum-e2e 2.1.0 → 2.3.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 +61 -470
- package/backend/lib/runnerProcess.js +50 -4
- package/backend/logs/runner-cmqneqerz0000qq01i5ap2rvl.log +22 -0
- package/backend/logs/runner-cmqnfv7kr0000r101aeocm8eu.log +20 -0
- package/backend/logs/runner-cmqnfvb560001r101qoi0phau.log +43 -0
- package/backend/logs/runner-cmqnfvlm20002r101gsyqb837.log +20 -0
- package/backend/logs/runner-cmqnfvqfy0003r101fh41pzx3.log +20 -0
- package/backend/logs/runner-cmqnfvvwo0004r101q4dtqxd2.log +20 -0
- package/backend/package.json +1 -0
- package/backend/prisma/migrations/20260621000000_add_notifications/migration.sql +8 -0
- package/backend/prisma/migrations/20260621000001_add_backup_config/migration.sql +11 -0
- package/backend/prisma/schema.prisma +22 -7
- package/backend/routes/backup.routes.js +70 -5
- package/backend/routes/node.routes.js +9 -0
- package/backend/routes/runners.routes.js +10 -0
- package/backend/routes/settings.routes.js +27 -0
- package/backend/scripts/manage-runners.mjs +49 -8
- package/backend/server.js +25 -1
- package/backend/services/backupCronService.js +82 -0
- package/backend/services/backupService.js +254 -27
- package/backend/services/cronService.js +91 -7
- package/backend/services/notificationService.js +163 -0
- package/backend/services/settingsService.js +74 -1
- package/backend/websockets/socketHandler.js +82 -6
- package/frontend/src/lib/api/schedules.js +5 -1
- package/frontend/src/lib/api/settings.js +56 -0
- package/frontend/src/lib/components/layout/RunnerPanel.svelte +79 -3
- package/frontend/src/lib/stores/runner.js +4 -2
- package/frontend/src/routes/scheduled-tests/+page.svelte +65 -7
- package/frontend/src/routes/settings/+page.svelte +472 -9
- package/package.json +1 -1
|
@@ -0,0 +1,163 @@
|
|
|
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
|
+
const settingsService = require('./settingsService');
|
|
19
|
+
|
|
20
|
+
function countScenarios(content) {
|
|
21
|
+
try {
|
|
22
|
+
// DB stores the processed format: { features: [{ scenarios: [{ status: 'passed'|'failed' }] }] }
|
|
23
|
+
const features = content?.features ?? (Array.isArray(content) ? content : []);
|
|
24
|
+
let passed = 0;
|
|
25
|
+
let failed = 0;
|
|
26
|
+
let total = 0;
|
|
27
|
+
for (const feature of features) {
|
|
28
|
+
for (const scenario of feature.scenarios ?? []) {
|
|
29
|
+
total++;
|
|
30
|
+
if (scenario.status === 'passed') passed++;
|
|
31
|
+
else failed++;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { passed, failed, total };
|
|
35
|
+
} catch {
|
|
36
|
+
return { passed: 0, failed: 0, total: 0 };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function buildDiscordPayload({ jobName, status, counts, browser, tags, reportUrl }) {
|
|
41
|
+
const isPass = status === 'PASS';
|
|
42
|
+
// Discord colour integers: green 3066993, red 15158332
|
|
43
|
+
const color = isPass ? 3066993 : 15158332;
|
|
44
|
+
const fields = [
|
|
45
|
+
{ name: 'Status', value: isPass ? '✅ PASS' : '❌ FAIL', inline: true },
|
|
46
|
+
{
|
|
47
|
+
name: 'Results',
|
|
48
|
+
value: `${counts.passed} / ${counts.total} passed`,
|
|
49
|
+
inline: true
|
|
50
|
+
},
|
|
51
|
+
{ name: 'Browser', value: browser ?? 'chromium', inline: true },
|
|
52
|
+
{ name: 'Tags', value: tags || '(all tests)', inline: false }
|
|
53
|
+
];
|
|
54
|
+
if (reportUrl) {
|
|
55
|
+
fields.push({ name: 'Report', value: `[View Report](${reportUrl})`, inline: false });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const embed = { title: jobName, color, fields, timestamp: new Date().toISOString() };
|
|
59
|
+
if (reportUrl) embed.url = reportUrl;
|
|
60
|
+
return { embeds: [embed] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function buildSlackPayload({ jobName, status, counts, browser, tags, reportUrl }) {
|
|
64
|
+
const isPass = status === 'PASS';
|
|
65
|
+
const icon = isPass ? '✅' : '❌';
|
|
66
|
+
const blocks = [
|
|
67
|
+
{
|
|
68
|
+
type: 'section',
|
|
69
|
+
text: {
|
|
70
|
+
type: 'mrkdwn',
|
|
71
|
+
text: `${icon} *${jobName}* — ${isPass ? 'PASS' : 'FAIL'}\n${counts.passed} / ${counts.total} scenarios passed`
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
type: 'section',
|
|
76
|
+
fields: [
|
|
77
|
+
{ type: 'mrkdwn', text: `*Browser:*\n${browser ?? 'chromium'}` },
|
|
78
|
+
{ type: 'mrkdwn', text: `*Tags:*\n${tags || '(all tests)'}` }
|
|
79
|
+
]
|
|
80
|
+
}
|
|
81
|
+
];
|
|
82
|
+
if (reportUrl) {
|
|
83
|
+
blocks.push({
|
|
84
|
+
type: 'actions',
|
|
85
|
+
elements: [
|
|
86
|
+
{
|
|
87
|
+
type: 'button',
|
|
88
|
+
text: { type: 'plain_text', text: 'View Report', emoji: true },
|
|
89
|
+
url: reportUrl
|
|
90
|
+
}
|
|
91
|
+
]
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return { blocks };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function postJson(url, body) {
|
|
98
|
+
const res = await fetch(url, {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
headers: { 'Content-Type': 'application/json' },
|
|
101
|
+
body: JSON.stringify(body),
|
|
102
|
+
signal: AbortSignal.timeout(8000)
|
|
103
|
+
});
|
|
104
|
+
if (!res.ok) {
|
|
105
|
+
console.error(`[notify] Webhook responded ${res.status}: ${url}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Sends Discord and/or Slack notifications for a completed test run.
|
|
111
|
+
*
|
|
112
|
+
* @param {{ jobName: string, status: string, content: object, browser: string,
|
|
113
|
+
* tags: string, reportId: number|null,
|
|
114
|
+
* notifyDiscord: boolean, notifySlack: boolean }} opts
|
|
115
|
+
*/
|
|
116
|
+
async function send({
|
|
117
|
+
jobName,
|
|
118
|
+
status,
|
|
119
|
+
content,
|
|
120
|
+
browser,
|
|
121
|
+
tags,
|
|
122
|
+
reportId,
|
|
123
|
+
notifyDiscord,
|
|
124
|
+
notifySlack
|
|
125
|
+
}) {
|
|
126
|
+
if (!notifyDiscord && !notifySlack) return;
|
|
127
|
+
|
|
128
|
+
let discordWebhookUrl, slackWebhookUrl, notifyPublicUrl;
|
|
129
|
+
try {
|
|
130
|
+
({ discordWebhookUrl, slackWebhookUrl, notifyPublicUrl } = await settingsService.getWebhooks());
|
|
131
|
+
} catch (e) {
|
|
132
|
+
console.error(`[notify] Could not load webhook settings: ${e.message}`);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const reportUrl =
|
|
137
|
+
notifyPublicUrl && reportId
|
|
138
|
+
? `${notifyPublicUrl.replace(/\/$/, '')}/reports/${reportId}`
|
|
139
|
+
: null;
|
|
140
|
+
|
|
141
|
+
const counts = countScenarios(content);
|
|
142
|
+
const data = { jobName, status, counts, browser, tags, reportUrl };
|
|
143
|
+
|
|
144
|
+
const tasks = [];
|
|
145
|
+
if (notifyDiscord && discordWebhookUrl) {
|
|
146
|
+
tasks.push(
|
|
147
|
+
postJson(discordWebhookUrl, buildDiscordPayload(data)).catch((e) =>
|
|
148
|
+
console.error(`[notify] Discord failed: ${e.message}`)
|
|
149
|
+
)
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
if (notifySlack && slackWebhookUrl) {
|
|
153
|
+
tasks.push(
|
|
154
|
+
postJson(slackWebhookUrl, buildSlackPayload(data)).catch((e) =>
|
|
155
|
+
console.error(`[notify] Slack failed: ${e.message}`)
|
|
156
|
+
)
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
await Promise.all(tasks);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { send };
|
|
@@ -49,4 +49,77 @@ const updateTestPrefixes = async ({ testCasePrefix, testSuitePrefix }) => {
|
|
|
49
49
|
});
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
const getWebhooks = async () => {
|
|
53
|
+
const project = await getProject();
|
|
54
|
+
return {
|
|
55
|
+
discordWebhookUrl: project.discordWebhookUrl ?? '',
|
|
56
|
+
slackWebhookUrl: project.slackWebhookUrl ?? '',
|
|
57
|
+
notifyPublicUrl: project.notifyPublicUrl ?? ''
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const updateWebhooks = async ({ discordWebhookUrl, slackWebhookUrl, notifyPublicUrl }) => {
|
|
62
|
+
return prisma.project.upsert({
|
|
63
|
+
where: { id: 1 },
|
|
64
|
+
create: { id: 1 },
|
|
65
|
+
update: {
|
|
66
|
+
discordWebhookUrl: discordWebhookUrl ?? '',
|
|
67
|
+
slackWebhookUrl: slackWebhookUrl ?? '',
|
|
68
|
+
notifyPublicUrl: notifyPublicUrl ?? ''
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const getBackupConfig = async () => {
|
|
74
|
+
const project = await getProject();
|
|
75
|
+
return {
|
|
76
|
+
backupEnabled: project.backupEnabled,
|
|
77
|
+
backupCron: project.backupCron,
|
|
78
|
+
backupS3Endpoint: project.backupS3Endpoint,
|
|
79
|
+
backupS3Region: project.backupS3Region,
|
|
80
|
+
backupS3Bucket: project.backupS3Bucket,
|
|
81
|
+
backupS3AccessKey: project.backupS3AccessKey,
|
|
82
|
+
backupS3SecretKeySet: project.backupS3SecretKey.length > 0,
|
|
83
|
+
backupS3Prefix: project.backupS3Prefix,
|
|
84
|
+
backupLastRunAt: project.backupLastRunAt,
|
|
85
|
+
backupLastStatus: project.backupLastStatus
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const updateBackupConfig = async ({
|
|
90
|
+
backupEnabled,
|
|
91
|
+
backupCron,
|
|
92
|
+
backupS3Endpoint,
|
|
93
|
+
backupS3Region,
|
|
94
|
+
backupS3Bucket,
|
|
95
|
+
backupS3AccessKey,
|
|
96
|
+
backupS3SecretKey,
|
|
97
|
+
backupS3Prefix
|
|
98
|
+
}) => {
|
|
99
|
+
const update = {
|
|
100
|
+
...(backupEnabled !== undefined && { backupEnabled }),
|
|
101
|
+
...(backupCron !== undefined && { backupCron }),
|
|
102
|
+
...(backupS3Endpoint !== undefined && { backupS3Endpoint }),
|
|
103
|
+
...(backupS3Region !== undefined && { backupS3Region }),
|
|
104
|
+
...(backupS3Bucket !== undefined && { backupS3Bucket }),
|
|
105
|
+
...(backupS3AccessKey !== undefined && { backupS3AccessKey }),
|
|
106
|
+
...(backupS3SecretKey && { backupS3SecretKey }),
|
|
107
|
+
...(backupS3Prefix !== undefined && { backupS3Prefix })
|
|
108
|
+
};
|
|
109
|
+
return prisma.project.upsert({
|
|
110
|
+
where: { id: 1 },
|
|
111
|
+
create: { id: 1, ...update },
|
|
112
|
+
update
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
module.exports = {
|
|
117
|
+
getProject,
|
|
118
|
+
updateProject,
|
|
119
|
+
getTestPrefixes,
|
|
120
|
+
updateTestPrefixes,
|
|
121
|
+
getWebhooks,
|
|
122
|
+
updateWebhooks,
|
|
123
|
+
getBackupConfig,
|
|
124
|
+
updateBackupConfig
|
|
125
|
+
};
|
|
@@ -18,9 +18,11 @@
|
|
|
18
18
|
const { spawn } = require('child_process');
|
|
19
19
|
const runnerService = require('../services/runnerService');
|
|
20
20
|
const reportService = require('../services/reportService');
|
|
21
|
+
const notificationService = require('../services/notificationService');
|
|
21
22
|
const { TRIGGER_TYPE, BUILT_IN_RUNNER_ID, TRIGGER_REMOTE } = require('../constants/triggers');
|
|
22
23
|
const { getTestIdsForTag, chunkTests, buildTagExpression } = require('../lib/testChunker');
|
|
23
24
|
const { readCucumberReportFile } = require('../lib/reportFilename');
|
|
25
|
+
const prisma = require('../services/prisma');
|
|
24
26
|
|
|
25
27
|
const socketHandler = (io) => {
|
|
26
28
|
io.on('connection', (socket) => {
|
|
@@ -30,13 +32,15 @@ const socketHandler = (io) => {
|
|
|
30
32
|
const activeProcs = new Set();
|
|
31
33
|
|
|
32
34
|
socket.on('run-test', async (payload, legacyWorkers) => {
|
|
33
|
-
let tag, workers, browser, runners, testRunId;
|
|
35
|
+
let tag, workers, browser, runners, testRunId, notifyDiscord, notifySlack;
|
|
34
36
|
if (typeof payload === 'string') {
|
|
35
37
|
tag = payload;
|
|
36
38
|
workers = Number(legacyWorkers) > 1 ? Number(legacyWorkers) : 1;
|
|
37
39
|
browser = 'chromium';
|
|
38
40
|
runners = [BUILT_IN_RUNNER_ID];
|
|
39
41
|
testRunId = null;
|
|
42
|
+
notifyDiscord = false;
|
|
43
|
+
notifySlack = false;
|
|
40
44
|
} else {
|
|
41
45
|
tag = payload.tag ?? '';
|
|
42
46
|
workers = Number(payload.workers) > 1 ? Number(payload.workers) : 1;
|
|
@@ -46,6 +50,8 @@ const socketHandler = (io) => {
|
|
|
46
50
|
? payload.runners
|
|
47
51
|
: [BUILT_IN_RUNNER_ID];
|
|
48
52
|
testRunId = payload.testRunId ?? null;
|
|
53
|
+
notifyDiscord = payload.notifyDiscord === true;
|
|
54
|
+
notifySlack = payload.notifySlack === true;
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
// Drop runner ids that no longer exist (e.g. a deleted runner still
|
|
@@ -63,9 +69,30 @@ const socketHandler = (io) => {
|
|
|
63
69
|
const isSingleBuiltIn = runners.length === 1 && runners[0] === BUILT_IN_RUNNER_ID;
|
|
64
70
|
|
|
65
71
|
if (isSingleBuiltIn) {
|
|
66
|
-
runBuiltIn(
|
|
72
|
+
runBuiltIn(
|
|
73
|
+
io,
|
|
74
|
+
socket,
|
|
75
|
+
activeProcs,
|
|
76
|
+
tag,
|
|
77
|
+
workers,
|
|
78
|
+
browser,
|
|
79
|
+
testRunId,
|
|
80
|
+
notifyDiscord,
|
|
81
|
+
notifySlack
|
|
82
|
+
);
|
|
67
83
|
} else {
|
|
68
|
-
runDistributed(
|
|
84
|
+
runDistributed(
|
|
85
|
+
io,
|
|
86
|
+
socket,
|
|
87
|
+
activeProcs,
|
|
88
|
+
tag,
|
|
89
|
+
workers,
|
|
90
|
+
browser,
|
|
91
|
+
runners,
|
|
92
|
+
testRunId,
|
|
93
|
+
notifyDiscord,
|
|
94
|
+
notifySlack
|
|
95
|
+
);
|
|
69
96
|
}
|
|
70
97
|
});
|
|
71
98
|
|
|
@@ -87,7 +114,17 @@ const socketHandler = (io) => {
|
|
|
87
114
|
// Single built-in runner
|
|
88
115
|
// ---------------------------------------------------------------------------
|
|
89
116
|
|
|
90
|
-
function runBuiltIn(
|
|
117
|
+
function runBuiltIn(
|
|
118
|
+
io,
|
|
119
|
+
socket,
|
|
120
|
+
activeProcs,
|
|
121
|
+
tag,
|
|
122
|
+
workers,
|
|
123
|
+
browser,
|
|
124
|
+
testRunId,
|
|
125
|
+
notifyDiscord,
|
|
126
|
+
notifySlack
|
|
127
|
+
) {
|
|
91
128
|
const env = {
|
|
92
129
|
...process.env,
|
|
93
130
|
TAG: tag,
|
|
@@ -108,8 +145,30 @@ function runBuiltIn(io, socket, activeProcs, tag, workers, browser, testRunId) {
|
|
|
108
145
|
activeProcs.delete(proc);
|
|
109
146
|
socket.emit('log', `\nTest finished with code ${code}`);
|
|
110
147
|
socket.emit('done', code);
|
|
111
|
-
// Notify all connected clients that a new report is available
|
|
112
148
|
io.emit('report-ready');
|
|
149
|
+
|
|
150
|
+
if (notifyDiscord || notifySlack) {
|
|
151
|
+
prisma.report
|
|
152
|
+
.findFirst({
|
|
153
|
+
where: { triggerType: TRIGGER_TYPE.MANUAL },
|
|
154
|
+
orderBy: { createdAt: 'desc' },
|
|
155
|
+
select: { id: true, status: true, content: true }
|
|
156
|
+
})
|
|
157
|
+
.then((report) => {
|
|
158
|
+
if (!report) return;
|
|
159
|
+
return notificationService.send({
|
|
160
|
+
jobName: 'Manual Run',
|
|
161
|
+
status: report.status,
|
|
162
|
+
content: report.content,
|
|
163
|
+
browser,
|
|
164
|
+
tags: tag,
|
|
165
|
+
reportId: report.id,
|
|
166
|
+
notifyDiscord,
|
|
167
|
+
notifySlack
|
|
168
|
+
});
|
|
169
|
+
})
|
|
170
|
+
.catch((e) => console.error(`[socket] Notification failed: ${e.message}`));
|
|
171
|
+
}
|
|
113
172
|
});
|
|
114
173
|
}
|
|
115
174
|
|
|
@@ -125,7 +184,9 @@ async function runDistributed(
|
|
|
125
184
|
workers,
|
|
126
185
|
browser,
|
|
127
186
|
runnerIds,
|
|
128
|
-
testRunId
|
|
187
|
+
testRunId,
|
|
188
|
+
notifyDiscord,
|
|
189
|
+
notifySlack
|
|
129
190
|
) {
|
|
130
191
|
const allIds = getTestIdsForTag(tag);
|
|
131
192
|
const chunks = chunkTests(allIds, runnerIds.length);
|
|
@@ -191,6 +252,21 @@ async function runDistributed(
|
|
|
191
252
|
// a passing run to "fail" in the live UI.
|
|
192
253
|
socket.emit('done', { code: saved.status === 'PASS' ? 0 : 1, reportId: saved.id });
|
|
193
254
|
io.emit('report-ready');
|
|
255
|
+
|
|
256
|
+
if (notifyDiscord || notifySlack) {
|
|
257
|
+
notificationService
|
|
258
|
+
.send({
|
|
259
|
+
jobName: 'Manual Run',
|
|
260
|
+
status: saved.status,
|
|
261
|
+
content: saved.content,
|
|
262
|
+
browser,
|
|
263
|
+
tags: tag,
|
|
264
|
+
reportId: saved.id,
|
|
265
|
+
notifyDiscord,
|
|
266
|
+
notifySlack
|
|
267
|
+
})
|
|
268
|
+
.catch((e) => console.error(`[socket] Notification failed: ${e.message}`));
|
|
269
|
+
}
|
|
194
270
|
})
|
|
195
271
|
.catch((e) => {
|
|
196
272
|
console.error('[runner] Failed to save combined report:', e.message);
|
|
@@ -30,6 +30,8 @@ export async function saveCronJob({
|
|
|
30
30
|
workers,
|
|
31
31
|
browser,
|
|
32
32
|
runnerIds,
|
|
33
|
+
notifyDiscord,
|
|
34
|
+
notifySlack,
|
|
33
35
|
isEditing,
|
|
34
36
|
editTaskName
|
|
35
37
|
}) {
|
|
@@ -47,7 +49,9 @@ export async function saveCronJob({
|
|
|
47
49
|
tags: formattedTags,
|
|
48
50
|
workers,
|
|
49
51
|
browser,
|
|
50
|
-
runnerIds
|
|
52
|
+
runnerIds,
|
|
53
|
+
notifyDiscord: notifyDiscord ?? false,
|
|
54
|
+
notifySlack: notifySlack ?? false
|
|
51
55
|
})
|
|
52
56
|
});
|
|
53
57
|
return res.json();
|
|
@@ -46,3 +46,59 @@ export async function importBackup(data) {
|
|
|
46
46
|
});
|
|
47
47
|
return res.json();
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
export async function fetchBackupConfig() {
|
|
51
|
+
const res = await fetch(`${API_BASE}/backup/config`);
|
|
52
|
+
if (!res.ok)
|
|
53
|
+
return {
|
|
54
|
+
backupEnabled: false,
|
|
55
|
+
backupCron: '0 2 * * *',
|
|
56
|
+
backupS3Endpoint: '',
|
|
57
|
+
backupS3Region: '',
|
|
58
|
+
backupS3Bucket: '',
|
|
59
|
+
backupS3AccessKey: '',
|
|
60
|
+
backupS3SecretKeySet: false,
|
|
61
|
+
backupS3Prefix: '',
|
|
62
|
+
backupLastRunAt: null,
|
|
63
|
+
backupLastStatus: ''
|
|
64
|
+
};
|
|
65
|
+
return res.json();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function saveBackupConfig(config) {
|
|
69
|
+
const res = await fetch(`${API_BASE}/backup/config`, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: { 'Content-Type': 'application/json' },
|
|
72
|
+
body: JSON.stringify(config)
|
|
73
|
+
});
|
|
74
|
+
return res.json();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function testBackupS3(config) {
|
|
78
|
+
const res = await fetch(`${API_BASE}/backup/test-s3`, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: { 'Content-Type': 'application/json' },
|
|
81
|
+
body: JSON.stringify(config)
|
|
82
|
+
});
|
|
83
|
+
return res.json();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function runBackupNow() {
|
|
87
|
+
const res = await fetch(`${API_BASE}/backup/run-now`, { method: 'POST' });
|
|
88
|
+
return res.json();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function fetchIntegrations() {
|
|
92
|
+
const res = await fetch(`${API_BASE}/settings/integrations`);
|
|
93
|
+
if (!res.ok) return { discordWebhookUrl: '', slackWebhookUrl: '', notifyPublicUrl: '' };
|
|
94
|
+
return res.json();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function saveIntegrations({ discordWebhookUrl, slackWebhookUrl, notifyPublicUrl }) {
|
|
98
|
+
const res = await fetch(`${API_BASE}/settings/integrations`, {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
headers: { 'Content-Type': 'application/json' },
|
|
101
|
+
body: JSON.stringify({ discordWebhookUrl, slackWebhookUrl, notifyPublicUrl })
|
|
102
|
+
});
|
|
103
|
+
return res.json();
|
|
104
|
+
}
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
import { fetchLatestReportId, reportUrl } from '$lib/api/reports';
|
|
35
35
|
import { fetchRunners } from '$lib/api/runners';
|
|
36
36
|
import { fetchRuns, fetchRun } from '$lib/api/repository';
|
|
37
|
+
import { fetchIntegrations } from '$lib/api/settings';
|
|
37
38
|
import { API_BASE, BROWSERS } from '$lib/constants';
|
|
38
39
|
import ConfirmModal from '$lib/components/ui/ConfirmModal.svelte';
|
|
39
40
|
|
|
@@ -45,6 +46,9 @@
|
|
|
45
46
|
let runnersOpen = false;
|
|
46
47
|
let runPickOpen = false;
|
|
47
48
|
let runAllModalOpen = false;
|
|
49
|
+
let integrations = { discordWebhookUrl: '', slackWebhookUrl: '', notifyPublicUrl: '' };
|
|
50
|
+
let notifyDiscord = false;
|
|
51
|
+
let notifySlack = false;
|
|
48
52
|
|
|
49
53
|
function clickOutside(node) {
|
|
50
54
|
function handle(e) {
|
|
@@ -87,6 +91,10 @@
|
|
|
87
91
|
})
|
|
88
92
|
.catch(() => {});
|
|
89
93
|
|
|
94
|
+
fetchIntegrations()
|
|
95
|
+
.then((i) => (integrations = i))
|
|
96
|
+
.catch(() => {});
|
|
97
|
+
|
|
90
98
|
_unsubConfig = runnerConfig.subscribe((v) => {
|
|
91
99
|
try {
|
|
92
100
|
localStorage.setItem('plum:runnerConfig', JSON.stringify(v));
|
|
@@ -251,14 +259,15 @@
|
|
|
251
259
|
}
|
|
252
260
|
|
|
253
261
|
function handleRunClick() {
|
|
262
|
+
const notify = { notifyDiscord, notifySlack };
|
|
254
263
|
if (selectedRun) {
|
|
255
264
|
if (selectedRunLoading || !selectedRun.tags) return;
|
|
256
265
|
if (selectedRun.tags.length === 0) return;
|
|
257
|
-
triggerRun(selectedRun.tags.join(' or '), selectedRun.id);
|
|
266
|
+
triggerRun(selectedRun.tags.join(' or '), selectedRun.id, notify);
|
|
258
267
|
} else if ($runnerConfig.testID.trim() === '') {
|
|
259
268
|
runAllModalOpen = true;
|
|
260
269
|
} else {
|
|
261
|
-
triggerRun();
|
|
270
|
+
triggerRun(undefined, undefined, notify);
|
|
262
271
|
}
|
|
263
272
|
}
|
|
264
273
|
|
|
@@ -294,7 +303,7 @@
|
|
|
294
303
|
confirmLabel="Run all tests"
|
|
295
304
|
on:confirm={() => {
|
|
296
305
|
runAllModalOpen = false;
|
|
297
|
-
triggerRun();
|
|
306
|
+
triggerRun(undefined, undefined, { notifyDiscord, notifySlack });
|
|
298
307
|
}}
|
|
299
308
|
>
|
|
300
309
|
No tag or filter is set. This will run <strong>every test</strong> in the suite, which may take a while.
|
|
@@ -586,6 +595,35 @@
|
|
|
586
595
|
</div>
|
|
587
596
|
{/if}
|
|
588
597
|
|
|
598
|
+
{#if integrations.discordWebhookUrl || integrations.slackWebhookUrl}
|
|
599
|
+
<div class="ctrl-divider"></div>
|
|
600
|
+
<div class="ctrl-group">
|
|
601
|
+
<span class="ctrl-label">Notify</span>
|
|
602
|
+
<div class="notify-toggles">
|
|
603
|
+
{#if integrations.discordWebhookUrl}
|
|
604
|
+
<button
|
|
605
|
+
type="button"
|
|
606
|
+
class="notify-btn"
|
|
607
|
+
class:active={notifyDiscord}
|
|
608
|
+
on:click={() => (notifyDiscord = !notifyDiscord)}
|
|
609
|
+
title={notifyDiscord ? 'Discord notification on' : 'Discord notification off'}
|
|
610
|
+
disabled={state.running}>Discord</button
|
|
611
|
+
>
|
|
612
|
+
{/if}
|
|
613
|
+
{#if integrations.slackWebhookUrl}
|
|
614
|
+
<button
|
|
615
|
+
type="button"
|
|
616
|
+
class="notify-btn"
|
|
617
|
+
class:active={notifySlack}
|
|
618
|
+
on:click={() => (notifySlack = !notifySlack)}
|
|
619
|
+
title={notifySlack ? 'Slack notification on' : 'Slack notification off'}
|
|
620
|
+
disabled={state.running}>Slack</button
|
|
621
|
+
>
|
|
622
|
+
{/if}
|
|
623
|
+
</div>
|
|
624
|
+
</div>
|
|
625
|
+
{/if}
|
|
626
|
+
|
|
589
627
|
<div class="ctrl-divider"></div>
|
|
590
628
|
|
|
591
629
|
<!-- Run button -->
|
|
@@ -1154,6 +1192,44 @@
|
|
|
1154
1192
|
}
|
|
1155
1193
|
}
|
|
1156
1194
|
|
|
1195
|
+
/* Notification toggles */
|
|
1196
|
+
.notify-toggles {
|
|
1197
|
+
display: flex;
|
|
1198
|
+
gap: 0.25rem;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
.notify-btn {
|
|
1202
|
+
height: 26px;
|
|
1203
|
+
padding: 0 0.5rem;
|
|
1204
|
+
border-radius: var(--radius-sm);
|
|
1205
|
+
border: 1px solid var(--border);
|
|
1206
|
+
background: transparent;
|
|
1207
|
+
color: var(--text-muted);
|
|
1208
|
+
font-size: 0.75rem;
|
|
1209
|
+
font-family: inherit;
|
|
1210
|
+
cursor: pointer;
|
|
1211
|
+
transition:
|
|
1212
|
+
background var(--duration-fast),
|
|
1213
|
+
color var(--duration-fast),
|
|
1214
|
+
border-color var(--duration-fast);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
.notify-btn:hover:not(:disabled) {
|
|
1218
|
+
color: var(--text);
|
|
1219
|
+
border-color: var(--text-muted);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
.notify-btn.active {
|
|
1223
|
+
background: var(--accent);
|
|
1224
|
+
border-color: var(--accent);
|
|
1225
|
+
color: #fff;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
.notify-btn:disabled {
|
|
1229
|
+
opacity: 0.4;
|
|
1230
|
+
cursor: default;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1157
1233
|
/* Expand button */
|
|
1158
1234
|
.expand-btn {
|
|
1159
1235
|
display: flex;
|
|
@@ -48,7 +48,7 @@ export const runsVersion = writable(0);
|
|
|
48
48
|
// Map of taskName → true for every cron job currently executing
|
|
49
49
|
export const activeCronJobs = writable({});
|
|
50
50
|
|
|
51
|
-
export function triggerRun(id, testRunId) {
|
|
51
|
+
export function triggerRun(id, testRunId, notify = {}) {
|
|
52
52
|
const s = get(socket);
|
|
53
53
|
if (!s) return;
|
|
54
54
|
|
|
@@ -72,7 +72,9 @@ export function triggerRun(id, testRunId) {
|
|
|
72
72
|
workers,
|
|
73
73
|
browser,
|
|
74
74
|
runners: selectedRunners,
|
|
75
|
-
testRunId: testRunId ?? null
|
|
75
|
+
testRunId: testRunId ?? null,
|
|
76
|
+
notifyDiscord: notify.notifyDiscord ?? false,
|
|
77
|
+
notifySlack: notify.notifySlack ?? false
|
|
76
78
|
});
|
|
77
79
|
}
|
|
78
80
|
|