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.
Files changed (31) hide show
  1. package/README.md +61 -470
  2. package/backend/lib/runnerProcess.js +50 -4
  3. package/backend/logs/runner-cmqneqerz0000qq01i5ap2rvl.log +22 -0
  4. package/backend/logs/runner-cmqnfv7kr0000r101aeocm8eu.log +20 -0
  5. package/backend/logs/runner-cmqnfvb560001r101qoi0phau.log +43 -0
  6. package/backend/logs/runner-cmqnfvlm20002r101gsyqb837.log +20 -0
  7. package/backend/logs/runner-cmqnfvqfy0003r101fh41pzx3.log +20 -0
  8. package/backend/logs/runner-cmqnfvvwo0004r101q4dtqxd2.log +20 -0
  9. package/backend/package.json +1 -0
  10. package/backend/prisma/migrations/20260621000000_add_notifications/migration.sql +8 -0
  11. package/backend/prisma/migrations/20260621000001_add_backup_config/migration.sql +11 -0
  12. package/backend/prisma/schema.prisma +22 -7
  13. package/backend/routes/backup.routes.js +70 -5
  14. package/backend/routes/node.routes.js +9 -0
  15. package/backend/routes/runners.routes.js +10 -0
  16. package/backend/routes/settings.routes.js +27 -0
  17. package/backend/scripts/manage-runners.mjs +49 -8
  18. package/backend/server.js +25 -1
  19. package/backend/services/backupCronService.js +82 -0
  20. package/backend/services/backupService.js +254 -27
  21. package/backend/services/cronService.js +91 -7
  22. package/backend/services/notificationService.js +163 -0
  23. package/backend/services/settingsService.js +74 -1
  24. package/backend/websockets/socketHandler.js +82 -6
  25. package/frontend/src/lib/api/schedules.js +5 -1
  26. package/frontend/src/lib/api/settings.js +56 -0
  27. package/frontend/src/lib/components/layout/RunnerPanel.svelte +79 -3
  28. package/frontend/src/lib/stores/runner.js +4 -2
  29. package/frontend/src/routes/scheduled-tests/+page.svelte +65 -7
  30. package/frontend/src/routes/settings/+page.svelte +472 -9
  31. 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
- module.exports = { getProject, updateProject, getTestPrefixes, updateTestPrefixes };
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(io, socket, activeProcs, tag, workers, browser, testRunId);
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(io, socket, activeProcs, tag, workers, browser, runners, testRunId);
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(io, socket, activeProcs, tag, workers, browser, testRunId) {
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