plum-e2e 2.1.0 → 2.2.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.
@@ -20,6 +20,7 @@ const { spawn } = require('child_process');
20
20
  const prisma = require('./prisma');
21
21
  const runnerService = require('./runnerService');
22
22
  const reportService = require('./reportService');
23
+ const notificationService = require('./notificationService');
23
24
  const { BUILT_IN_RUNNER_ID, TRIGGER_REMOTE } = require('../constants/triggers');
24
25
  const { getTestIdsForTag, chunkTests, buildTagExpression } = require('../lib/testChunker');
25
26
  const { readCucumberReportFile } = require('../lib/reportFilename');
@@ -63,7 +64,7 @@ async function resolveLaneInfos(runnerIds) {
63
64
  * Single built-in runner — spawns tests locally.
64
65
  * TRIGGER is set to taskName so generate-report.js can persist it correctly.
65
66
  */
66
- function runSingleBuiltIn({ taskName, tags, workers, browser }) {
67
+ function runSingleBuiltIn({ taskName, tags, workers, browser, notifyDiscord, notifySlack }) {
67
68
  const env = {
68
69
  ...process.env,
69
70
  TAG: tags,
@@ -78,6 +79,29 @@ function runSingleBuiltIn({ taskName, tags, workers, browser }) {
78
79
  task.on('close', (code) => {
79
80
  console.log(`Task "${taskName}" finished with code ${code}`);
80
81
  if (_io) _io.emit('cron-done', { taskName, code });
82
+
83
+ if (notifyDiscord || notifySlack) {
84
+ prisma.report
85
+ .findFirst({
86
+ where: { triggerType: taskName },
87
+ orderBy: { createdAt: 'desc' },
88
+ select: { id: true, status: true, content: true }
89
+ })
90
+ .then((report) => {
91
+ if (!report) return;
92
+ return notificationService.send({
93
+ jobName: taskName,
94
+ status: report.status,
95
+ content: report.content,
96
+ browser,
97
+ tags,
98
+ reportId: report.id,
99
+ notifyDiscord,
100
+ notifySlack
101
+ });
102
+ })
103
+ .catch((e) => console.error(`[cron] Notification failed: ${e.message}`));
104
+ }
81
105
  });
82
106
  }
83
107
 
@@ -85,7 +109,15 @@ function runSingleBuiltIn({ taskName, tags, workers, browser }) {
85
109
  * Multi-runner distributed path — splits tests across nodes and combines reports.
86
110
  * triggerType = taskName so the combined report is correctly attributed.
87
111
  */
88
- async function runDistributed({ taskName, tags, workers, browser, runnerIds }) {
112
+ async function runDistributed({
113
+ taskName,
114
+ tags,
115
+ workers,
116
+ browser,
117
+ runnerIds,
118
+ notifyDiscord,
119
+ notifySlack
120
+ }) {
89
121
  const allIds = getTestIdsForTag(tags);
90
122
  const chunks = chunkTests(allIds, runnerIds.length);
91
123
 
@@ -123,6 +155,20 @@ async function runDistributed({ taskName, tags, workers, browser, runnerIds }) {
123
155
  triggerType: taskName,
124
156
  browser
125
157
  })
158
+ .then((saved) => {
159
+ if (saved && (notifyDiscord || notifySlack)) {
160
+ return notificationService.send({
161
+ jobName: taskName,
162
+ status: saved.status,
163
+ content: saved.content,
164
+ browser,
165
+ tags,
166
+ reportId: saved.id,
167
+ notifyDiscord,
168
+ notifySlack
169
+ });
170
+ }
171
+ })
126
172
  .catch((e) => console.error(`[cron] Failed to save combined report: ${e.message}`));
127
173
  }
128
174
  }
@@ -163,7 +209,15 @@ async function runDistributed({ taskName, tags, workers, browser, runnerIds }) {
163
209
  // ---------------------------------------------------------------------------
164
210
 
165
211
  async function runCronJob(job) {
166
- const { taskName, tags, workers, browser, runnerIds: runnerIdsStr } = job;
212
+ const {
213
+ taskName,
214
+ tags,
215
+ workers,
216
+ browser,
217
+ runnerIds: runnerIdsStr,
218
+ notifyDiscord,
219
+ notifySlack
220
+ } = job;
167
221
  const runnerIds = parseRunnerIds(runnerIdsStr);
168
222
 
169
223
  if (_io) _io.emit('cron-start', { taskName });
@@ -172,9 +226,17 @@ async function runCronJob(job) {
172
226
  const isSingleBuiltIn = runnerIds.length === 1 && runnerIds[0] === BUILT_IN_RUNNER_ID;
173
227
 
174
228
  if (isSingleBuiltIn) {
175
- runSingleBuiltIn({ taskName, tags, workers, browser });
229
+ runSingleBuiltIn({ taskName, tags, workers, browser, notifyDiscord, notifySlack });
176
230
  } else {
177
- await runDistributed({ taskName, tags, workers, browser, runnerIds });
231
+ await runDistributed({
232
+ taskName,
233
+ tags,
234
+ workers,
235
+ browser,
236
+ runnerIds,
237
+ notifyDiscord,
238
+ notifySlack
239
+ });
178
240
  }
179
241
  }
180
242
 
@@ -212,7 +274,16 @@ const reload = async () => {
212
274
 
213
275
  const getAllCronJobs = () => prisma.cronJob.findMany({ orderBy: { createdAt: 'asc' } });
214
276
 
215
- const addCronJob = async ({ taskName, cronExpression, tags, workers, browser, runnerIds }) => {
277
+ const addCronJob = async ({
278
+ taskName,
279
+ cronExpression,
280
+ tags,
281
+ workers,
282
+ browser,
283
+ runnerIds,
284
+ notifyDiscord,
285
+ notifySlack
286
+ }) => {
216
287
  if (!cronExpression || !taskName || !tags) {
217
288
  return { status: 400, message: 'Missing required parameters' };
218
289
  }
@@ -227,6 +298,8 @@ const addCronJob = async ({ taskName, cronExpression, tags, workers, browser, ru
227
298
  workers: workers ?? 1,
228
299
  browser: browser ?? 'chromium',
229
300
  runnerIds: runnerIdsStr,
301
+ notifyDiscord: notifyDiscord ?? false,
302
+ notifySlack: notifySlack ?? false,
230
303
  runnerId: null
231
304
  }
232
305
  });
@@ -248,7 +321,16 @@ const removeCronJob = async (taskName) => {
248
321
 
249
322
  const updateCronJob = async (
250
323
  oldTaskName,
251
- { taskName: newTaskName, cronExpression, tags, workers, browser, runnerIds }
324
+ {
325
+ taskName: newTaskName,
326
+ cronExpression,
327
+ tags,
328
+ workers,
329
+ browser,
330
+ runnerIds,
331
+ notifyDiscord,
332
+ notifySlack
333
+ }
252
334
  ) => {
253
335
  const job = await prisma.cronJob.findUnique({ where: { taskName: oldTaskName } });
254
336
  if (!job) return { status: 404, message: `Cron job "${oldTaskName}" not found` };
@@ -271,6 +353,8 @@ const updateCronJob = async (
271
353
  workers: workers ?? 1,
272
354
  browser: browser ?? 'chromium',
273
355
  runnerIds: runnerIdsStr,
356
+ notifyDiscord: notifyDiscord ?? false,
357
+ notifySlack: notifySlack ?? false,
274
358
  runnerId: null
275
359
  }
276
360
  });
@@ -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,32 @@ 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
+ module.exports = {
74
+ getProject,
75
+ updateProject,
76
+ getTestPrefixes,
77
+ updateTestPrefixes,
78
+ getWebhooks,
79
+ updateWebhooks
80
+ };
@@ -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,18 @@ export async function importBackup(data) {
46
46
  });
47
47
  return res.json();
48
48
  }
49
+
50
+ export async function fetchIntegrations() {
51
+ const res = await fetch(`${API_BASE}/settings/integrations`);
52
+ if (!res.ok) return { discordWebhookUrl: '', slackWebhookUrl: '', notifyPublicUrl: '' };
53
+ return res.json();
54
+ }
55
+
56
+ export async function saveIntegrations({ discordWebhookUrl, slackWebhookUrl, notifyPublicUrl }) {
57
+ const res = await fetch(`${API_BASE}/settings/integrations`, {
58
+ method: 'POST',
59
+ headers: { 'Content-Type': 'application/json' },
60
+ body: JSON.stringify({ discordWebhookUrl, slackWebhookUrl, notifyPublicUrl })
61
+ });
62
+ return res.json();
63
+ }
@@ -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;