plum-e2e 1.3.7 → 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.
Files changed (62) hide show
  1. package/README.md +111 -3
  2. package/backend/app.js +5 -0
  3. package/backend/config/scripts/create-test.mjs +172 -0
  4. package/backend/config/scripts/generate-report.js +2 -1
  5. package/backend/lib/runnerProcess.js +50 -4
  6. package/backend/logs/runner-cmqneqerz0000qq01i5ap2rvl.log +22 -0
  7. package/backend/logs/runner-cmqnfv7kr0000r101aeocm8eu.log +20 -0
  8. package/backend/logs/runner-cmqnfvb560001r101qoi0phau.log +43 -0
  9. package/backend/logs/runner-cmqnfvlm20002r101gsyqb837.log +20 -0
  10. package/backend/logs/runner-cmqnfvqfy0003r101fh41pzx3.log +20 -0
  11. package/backend/logs/runner-cmqnfvvwo0004r101q4dtqxd2.log +20 -0
  12. package/backend/middleware/jwtAuth.js +33 -0
  13. package/backend/middleware/requireAdmin.js +25 -0
  14. package/backend/package.json +2 -0
  15. package/backend/prisma/migrations/20260618000000_add_test_repository/migration.sql +133 -0
  16. package/backend/prisma/migrations/20260618000001_add_user_roles/migration.sql +3 -0
  17. package/backend/prisma/migrations/20260618000002_drop_automated_tag/migration.sql +2 -0
  18. package/backend/prisma/migrations/20260618000003_entry_assignee/migration.sql +2 -0
  19. package/backend/prisma/migrations/20260621000000_add_notifications/migration.sql +8 -0
  20. package/backend/prisma/schema.prisma +123 -10
  21. package/backend/routes/auth.routes.js +96 -0
  22. package/backend/routes/node.routes.js +9 -0
  23. package/backend/routes/runners.routes.js +10 -0
  24. package/backend/routes/settings.routes.js +71 -8
  25. package/backend/routes/test-cases.routes.js +80 -0
  26. package/backend/routes/test-runs.routes.js +122 -0
  27. package/backend/routes/test-suites.routes.js +92 -0
  28. package/backend/routes/users.routes.js +67 -0
  29. package/backend/scripts/create-test.js +7 -6
  30. package/backend/scripts/manage-runners.mjs +49 -8
  31. package/backend/server.js +22 -1
  32. package/backend/services/cronService.js +91 -7
  33. package/backend/services/notificationService.js +163 -0
  34. package/backend/services/reportService.js +96 -4
  35. package/backend/services/settingsService.js +46 -2
  36. package/backend/services/testCaseService.js +139 -0
  37. package/backend/services/testRunService.js +203 -0
  38. package/backend/services/testSuiteService.js +191 -0
  39. package/backend/services/userService.js +114 -0
  40. package/backend/websockets/socketHandler.js +96 -7
  41. package/bin/plum.js +105 -9
  42. package/frontend/src/lib/api/auth.js +69 -0
  43. package/frontend/src/lib/api/repository.js +256 -0
  44. package/frontend/src/lib/api/schedules.js +5 -1
  45. package/frontend/src/lib/api/settings.js +15 -0
  46. package/frontend/src/lib/api/users.js +52 -0
  47. package/frontend/src/lib/components/layout/Nav.svelte +116 -4
  48. package/frontend/src/lib/components/layout/RunnerPanel.svelte +321 -31
  49. package/frontend/src/lib/components/ui/Modal.svelte +8 -1
  50. package/frontend/src/lib/constants.js +2 -0
  51. package/frontend/src/lib/stores/auth.js +60 -0
  52. package/frontend/src/lib/stores/runner.js +11 -2
  53. package/frontend/src/routes/+layout.svelte +32 -4
  54. package/frontend/src/routes/+page.svelte +1 -1
  55. package/frontend/src/routes/login/+page.svelte +209 -0
  56. package/frontend/src/routes/scheduled-tests/+page.svelte +65 -7
  57. package/frontend/src/routes/settings/+page.svelte +677 -6
  58. package/frontend/src/routes/setup/+page.svelte +249 -0
  59. package/frontend/src/routes/test-repository/+page.svelte +1379 -0
  60. package/frontend/src/routes/test-repository/runs/[id]/+page.svelte +1549 -0
  61. package/frontend/src/routes/test-repository/suites/[id]/+page.svelte +1490 -0
  62. 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 };
@@ -22,6 +22,85 @@ const prisma = require('./prisma');
22
22
  const { isScheduledTrigger, normaliseTrigger } = require('../constants/triggers');
23
23
  const { SCREENSHOTS_DIR } = require('../lib/reportFilename');
24
24
 
25
+ // ---------------------------------------------------------------------------
26
+ // Auto-sync: mark test cases as automated and record history from Cucumber tags
27
+ // ---------------------------------------------------------------------------
28
+
29
+ async function syncAutomatedTags(reportId, features, testRunId = null) {
30
+ try {
31
+ const tagSet = new Set();
32
+ for (const feature of features) {
33
+ for (const scenario of feature.scenarios ?? []) {
34
+ for (const tag of scenario.tags ?? []) {
35
+ tagSet.add(tag.replace(/^@/, ''));
36
+ }
37
+ }
38
+ }
39
+ if (tagSet.size === 0) return;
40
+
41
+ const matchingCases = await prisma.testCase.findMany({
42
+ where: { displayId: { in: [...tagSet] } },
43
+ select: { id: true, displayId: true }
44
+ });
45
+ if (matchingCases.length === 0) return;
46
+
47
+ const tagToResult = new Map();
48
+ for (const feature of features) {
49
+ for (const scenario of feature.scenarios ?? []) {
50
+ for (const tag of scenario.tags ?? []) {
51
+ const t = tag.replace(/^@/, '');
52
+ const result = scenario.status === 'passed' ? 'pass' : 'fail';
53
+ if (!tagToResult.has(t) || result === 'fail') {
54
+ tagToResult.set(t, result);
55
+ }
56
+ }
57
+ }
58
+ }
59
+
60
+ await prisma.$transaction([
61
+ ...matchingCases.map((tc) =>
62
+ prisma.testCase.update({ where: { id: tc.id }, data: { isAutomated: true } })
63
+ ),
64
+ ...matchingCases.map((tc) =>
65
+ prisma.testCaseHistory.create({
66
+ data: {
67
+ caseId: tc.id,
68
+ reportId,
69
+ result: tagToResult.get(tc.displayId) ?? 'pass',
70
+ source: 'automated'
71
+ }
72
+ })
73
+ )
74
+ ]);
75
+
76
+ if (testRunId) {
77
+ const entries = await prisma.testRunEntry.findMany({
78
+ where: { runId: testRunId, case: { displayId: { in: [...tagToResult.keys()] } } },
79
+ select: { id: true, case: { select: { displayId: true } } }
80
+ });
81
+ if (entries.length > 0) {
82
+ await prisma.$transaction([
83
+ ...entries.map((e) =>
84
+ prisma.testRunEntry.update({
85
+ where: { id: e.id },
86
+ data: {
87
+ status: tagToResult.get(e.case.displayId) ?? 'pass',
88
+ executedAt: new Date()
89
+ }
90
+ })
91
+ ),
92
+ prisma.testRun.updateMany({
93
+ where: { id: testRunId, status: { in: ['backlog', 'draft'] } },
94
+ data: { status: 'in-progress' }
95
+ })
96
+ ]);
97
+ }
98
+ }
99
+ } catch (e) {
100
+ console.error('[sync] Failed to sync automated tags:', e.message);
101
+ }
102
+ }
103
+
25
104
  // ---------------------------------------------------------------------------
26
105
  // Internal helpers
27
106
  // ---------------------------------------------------------------------------
@@ -207,6 +286,7 @@ const getReportDetail = async (id) => {
207
286
  * browser?: string,
208
287
  * runnerName?: string,
209
288
  * runnerId?: string,
289
+ * testRunId?: string,
210
290
  * }} opts
211
291
  */
212
292
  const saveReport = async ({
@@ -216,13 +296,14 @@ const saveReport = async ({
216
296
  nodeCount,
217
297
  browser,
218
298
  runnerName,
219
- runnerId
299
+ runnerId,
300
+ testRunId
220
301
  }) => {
221
302
  const normTrigger = normaliseTrigger(triggerType);
222
303
  const { features, status } = processCucumberJson(rawCucumberJson);
223
304
  const cronJobId = await resolveCronJobId(normTrigger);
224
305
 
225
- return prisma.report.create({
306
+ const report = await prisma.report.create({
226
307
  data: {
227
308
  status,
228
309
  tags: (tags ?? '').replace(/^\(|\)$/g, '') || '@all-tests',
@@ -235,6 +316,8 @@ const saveReport = async ({
235
316
  content: { features }
236
317
  }
237
318
  });
319
+ syncAutomatedTags(report.id, features, testRunId ?? null);
320
+ return report;
238
321
  };
239
322
 
240
323
  /**
@@ -250,7 +333,15 @@ const saveReport = async ({
250
333
  * browser: string,
251
334
  * }} opts
252
335
  */
253
- const saveCombinedReport = async ({ reports, runners, overallCode, tag, triggerType, browser }) => {
336
+ const saveCombinedReport = async ({
337
+ reports,
338
+ runners,
339
+ overallCode,
340
+ tag,
341
+ triggerType,
342
+ browser,
343
+ testRunId
344
+ }) => {
254
345
  const featureMap = new Map();
255
346
  for (const content of reports) {
256
347
  if (!content) continue;
@@ -282,7 +373,8 @@ const saveCombinedReport = async ({ reports, runners, overallCode, tag, triggerT
282
373
  nodeCount: runners.length,
283
374
  browser,
284
375
  runnerName: runners.map((r) => r.name).join(', '),
285
- runnerId: null
376
+ runnerId: null,
377
+ testRunId: testRunId ?? null
286
378
  });
287
379
  };
288
380
 
@@ -20,7 +20,7 @@ const prisma = require('./prisma');
20
20
  const getProject = async () => {
21
21
  let project = await prisma.project.findUnique({ where: { id: 1 } });
22
22
  if (!project) {
23
- project = await prisma.project.create({ data: { id: 1, name: '', logoUrl: '' } });
23
+ project = await prisma.project.create({ data: { id: 1 } });
24
24
  }
25
25
  return project;
26
26
  };
@@ -33,4 +33,48 @@ const updateProject = async ({ name, logoUrl }) => {
33
33
  });
34
34
  };
35
35
 
36
- module.exports = { getProject, updateProject };
36
+ const getTestPrefixes = async () => {
37
+ const project = await getProject();
38
+ return { testCasePrefix: project.testCasePrefix, testSuitePrefix: project.testSuitePrefix };
39
+ };
40
+
41
+ const updateTestPrefixes = async ({ testCasePrefix, testSuitePrefix }) => {
42
+ return prisma.project.upsert({
43
+ where: { id: 1 },
44
+ create: { id: 1 },
45
+ update: {
46
+ ...(testCasePrefix !== undefined && { testCasePrefix }),
47
+ ...(testSuitePrefix !== undefined && { testSuitePrefix })
48
+ }
49
+ });
50
+ };
51
+
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
+ };
@@ -0,0 +1,139 @@
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 prisma = require('./prisma');
19
+
20
+ const caseSelect = {
21
+ id: true,
22
+ displayId: true,
23
+ title: true,
24
+ description: true,
25
+ priority: true,
26
+ isAutomated: true,
27
+ suiteId: true,
28
+ createdAt: true,
29
+ updatedAt: true,
30
+ createdBy: { select: { id: true, name: true } },
31
+ suite: { select: { id: true, displayId: true, name: true } },
32
+ _count: { select: { steps: true } }
33
+ };
34
+
35
+ async function getById(id) {
36
+ return prisma.testCase.findUnique({
37
+ where: { id },
38
+ select: {
39
+ ...caseSelect,
40
+ steps: {
41
+ orderBy: { order: 'asc' }
42
+ },
43
+ history: {
44
+ select: {
45
+ id: true,
46
+ result: true,
47
+ source: true,
48
+ notes: true,
49
+ executedAt: true,
50
+ executedBy: { select: { id: true, name: true } },
51
+ run: { select: { id: true, title: true } },
52
+ report: { select: { id: true, status: true, createdAt: true } }
53
+ },
54
+ orderBy: { executedAt: 'desc' },
55
+ take: 50
56
+ }
57
+ }
58
+ });
59
+ }
60
+
61
+ async function create({ suiteId, title, description, priority, createdById }) {
62
+ const project = await prisma.project.upsert({
63
+ where: { id: 1 },
64
+ create: { id: 1, caseSeqNext: 1 },
65
+ update: { caseSeqNext: { increment: 1 } },
66
+ select: { caseSeqNext: true, testCasePrefix: true }
67
+ });
68
+ const num = String(project.caseSeqNext).padStart(3, '0');
69
+ const displayId = `${project.testCasePrefix}-${num}`;
70
+
71
+ return prisma.testCase.create({
72
+ data: {
73
+ displayId,
74
+ suiteId,
75
+ title,
76
+ description: description ?? '',
77
+ priority: priority ?? 'Medium',
78
+ createdById
79
+ },
80
+ select: caseSelect
81
+ });
82
+ }
83
+
84
+ async function update(id, { title, description, priority }) {
85
+ return prisma.testCase.update({
86
+ where: { id },
87
+ data: {
88
+ ...(title !== undefined && { title }),
89
+ ...(description !== undefined && { description }),
90
+ ...(priority !== undefined && { priority })
91
+ },
92
+ select: caseSelect
93
+ });
94
+ }
95
+
96
+ async function remove(id) {
97
+ return prisma.testCase.delete({ where: { id } });
98
+ }
99
+
100
+ async function upsertSteps(caseId, steps) {
101
+ await prisma.testStep.deleteMany({ where: { caseId } });
102
+ if (!steps || steps.length === 0) return [];
103
+ return prisma.$transaction(
104
+ steps.map((step, i) =>
105
+ prisma.testStep.create({
106
+ data: {
107
+ caseId,
108
+ action: step.action ?? '',
109
+ testData: step.testData ?? '',
110
+ expectedOutput: step.expectedOutput ?? '',
111
+ order: i
112
+ }
113
+ })
114
+ )
115
+ );
116
+ }
117
+
118
+ async function migratePrefix(newPrefix) {
119
+ const cases = await prisma.testCase.findMany({
120
+ select: { id: true, displayId: true },
121
+ orderBy: { createdAt: 'asc' }
122
+ });
123
+ const project = await prisma.project.upsert({
124
+ where: { id: 1 },
125
+ create: { id: 1 },
126
+ update: { testCasePrefix: newPrefix },
127
+ select: { testCasePrefix: true }
128
+ });
129
+ for (let i = 0; i < cases.length; i++) {
130
+ const num = String(i + 1).padStart(3, '0');
131
+ await prisma.testCase.update({
132
+ where: { id: cases[i].id },
133
+ data: { displayId: `${newPrefix}-${num}` }
134
+ });
135
+ }
136
+ return project;
137
+ }
138
+
139
+ module.exports = { getById, create, update, remove, upsertSteps, migratePrefix };
@@ -0,0 +1,203 @@
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 prisma = require('./prisma');
19
+
20
+ const runListSelect = {
21
+ id: true,
22
+ title: true,
23
+ status: true,
24
+ createdAt: true,
25
+ updatedAt: true,
26
+ createdBy: { select: { id: true, name: true } },
27
+ _count: { select: { entries: true } }
28
+ };
29
+
30
+ function runOrderBy(sortBy, sortOrder) {
31
+ const dir = sortOrder === 'desc' ? 'desc' : 'asc';
32
+ if (sortBy === 'title') return { title: dir };
33
+ if (sortBy === 'status') return { status: dir };
34
+ if (sortBy === 'updatedAt') return { updatedAt: dir };
35
+ return { createdAt: dir };
36
+ }
37
+
38
+ async function getAll({ page = 1, limit = 20, q, sortBy = 'createdAt', sortOrder = 'desc' } = {}) {
39
+ const skip = (page - 1) * limit;
40
+ const where = q ? { title: { contains: q, mode: 'insensitive' } } : {};
41
+ const orderBy = runOrderBy(sortBy, sortOrder);
42
+ const [runs, total] = await Promise.all([
43
+ prisma.testRun.findMany({ where, select: runListSelect, orderBy, skip, take: limit }),
44
+ prisma.testRun.count({ where })
45
+ ]);
46
+ return { runs, total };
47
+ }
48
+
49
+ async function getById(id) {
50
+ return prisma.testRun.findUnique({
51
+ where: { id },
52
+ select: {
53
+ id: true,
54
+ title: true,
55
+ status: true,
56
+ createdAt: true,
57
+ updatedAt: true,
58
+ createdBy: { select: { id: true, name: true } },
59
+ entries: {
60
+ select: {
61
+ id: true,
62
+ order: true,
63
+ status: true,
64
+ notes: true,
65
+ executedAt: true,
66
+ executedBy: { select: { id: true, name: true } },
67
+ assignedToId: true,
68
+ assignedTo: { select: { id: true, name: true } },
69
+ case: {
70
+ select: {
71
+ id: true,
72
+ displayId: true,
73
+ title: true,
74
+ priority: true,
75
+ isAutomated: true,
76
+ suite: { select: { id: true, name: true, displayId: true } },
77
+ steps: { orderBy: { order: 'asc' } }
78
+ }
79
+ }
80
+ },
81
+ orderBy: { order: 'asc' }
82
+ }
83
+ }
84
+ });
85
+ }
86
+
87
+ async function create({ title, caseIds, createdById }) {
88
+ const run = await prisma.testRun.create({
89
+ data: {
90
+ title,
91
+ status: 'backlog',
92
+ createdById,
93
+ entries: {
94
+ create: (caseIds ?? []).map((caseId, i) => ({ caseId, order: i }))
95
+ }
96
+ },
97
+ select: runListSelect
98
+ });
99
+ return run;
100
+ }
101
+
102
+ async function update(id, { title, status, caseIds }) {
103
+ const data = {};
104
+ if (title !== undefined) data.title = title;
105
+ if (status !== undefined) data.status = status;
106
+
107
+ if (caseIds !== undefined) {
108
+ await prisma.testRunEntry.deleteMany({ where: { runId: id } });
109
+ await prisma.$transaction(
110
+ caseIds.map((caseId, i) =>
111
+ prisma.testRunEntry.create({ data: { runId: id, caseId, order: i } })
112
+ )
113
+ );
114
+ }
115
+
116
+ return prisma.testRun.update({ where: { id }, data, select: runListSelect });
117
+ }
118
+
119
+ async function duplicate(id, { createdById }) {
120
+ const original = await prisma.testRun.findUnique({
121
+ where: { id },
122
+ select: {
123
+ title: true,
124
+ entries: { select: { caseId: true, order: true }, orderBy: { order: 'asc' } }
125
+ }
126
+ });
127
+ if (!original) return null;
128
+ return prisma.testRun.create({
129
+ data: {
130
+ title: `Copy of ${original.title}`,
131
+ createdById,
132
+ entries: { create: original.entries.map((e, i) => ({ caseId: e.caseId, order: i })) }
133
+ },
134
+ select: runListSelect
135
+ });
136
+ }
137
+
138
+ async function remove(id) {
139
+ return prisma.testRun.delete({ where: { id } });
140
+ }
141
+
142
+ async function updateEntry(entryId, { status, notes, executedById }) {
143
+ const entry = await prisma.testRunEntry.update({
144
+ where: { id: entryId },
145
+ data: {
146
+ status,
147
+ notes: notes ?? '',
148
+ executedById: executedById ?? null,
149
+ executedAt: new Date()
150
+ },
151
+ select: {
152
+ id: true,
153
+ status: true,
154
+ notes: true,
155
+ executedAt: true,
156
+ runId: true,
157
+ caseId: true
158
+ }
159
+ });
160
+
161
+ if (status === 'pass' || status === 'fail' || status === 'blocked' || status === 'skip') {
162
+ await prisma.testCaseHistory.create({
163
+ data: {
164
+ caseId: entry.caseId,
165
+ runId: entry.runId,
166
+ result: status,
167
+ source: 'manual',
168
+ notes: notes ?? '',
169
+ executedById: executedById ?? null
170
+ }
171
+ });
172
+ }
173
+
174
+ return entry;
175
+ }
176
+
177
+ async function assignEntry(entryId, { userId }) {
178
+ return prisma.testRunEntry.update({
179
+ where: { id: entryId },
180
+ data: { assignedToId: userId ?? null },
181
+ select: { id: true, assignedToId: true, assignedTo: { select: { id: true, name: true } } }
182
+ });
183
+ }
184
+
185
+ async function reorderEntries(runId, orderedEntryIds) {
186
+ await prisma.$transaction(
187
+ orderedEntryIds.map((entryId, i) =>
188
+ prisma.testRunEntry.update({ where: { id: entryId }, data: { order: i } })
189
+ )
190
+ );
191
+ }
192
+
193
+ module.exports = {
194
+ getAll,
195
+ getById,
196
+ create,
197
+ update,
198
+ duplicate,
199
+ remove,
200
+ updateEntry,
201
+ assignEntry,
202
+ reorderEntries
203
+ };