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.
- package/README.md +111 -3
- package/backend/app.js +5 -0
- package/backend/config/scripts/create-test.mjs +172 -0
- package/backend/config/scripts/generate-report.js +2 -1
- 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/middleware/jwtAuth.js +33 -0
- package/backend/middleware/requireAdmin.js +25 -0
- package/backend/package.json +2 -0
- package/backend/prisma/migrations/20260618000000_add_test_repository/migration.sql +133 -0
- package/backend/prisma/migrations/20260618000001_add_user_roles/migration.sql +3 -0
- package/backend/prisma/migrations/20260618000002_drop_automated_tag/migration.sql +2 -0
- package/backend/prisma/migrations/20260618000003_entry_assignee/migration.sql +2 -0
- package/backend/prisma/migrations/20260621000000_add_notifications/migration.sql +8 -0
- package/backend/prisma/schema.prisma +123 -10
- package/backend/routes/auth.routes.js +96 -0
- package/backend/routes/node.routes.js +9 -0
- package/backend/routes/runners.routes.js +10 -0
- package/backend/routes/settings.routes.js +71 -8
- package/backend/routes/test-cases.routes.js +80 -0
- package/backend/routes/test-runs.routes.js +122 -0
- package/backend/routes/test-suites.routes.js +92 -0
- package/backend/routes/users.routes.js +67 -0
- package/backend/scripts/create-test.js +7 -6
- package/backend/scripts/manage-runners.mjs +49 -8
- package/backend/server.js +22 -1
- package/backend/services/cronService.js +91 -7
- package/backend/services/notificationService.js +163 -0
- package/backend/services/reportService.js +96 -4
- package/backend/services/settingsService.js +46 -2
- package/backend/services/testCaseService.js +139 -0
- package/backend/services/testRunService.js +203 -0
- package/backend/services/testSuiteService.js +191 -0
- package/backend/services/userService.js +114 -0
- package/backend/websockets/socketHandler.js +96 -7
- package/bin/plum.js +105 -9
- package/frontend/src/lib/api/auth.js +69 -0
- package/frontend/src/lib/api/repository.js +256 -0
- package/frontend/src/lib/api/schedules.js +5 -1
- package/frontend/src/lib/api/settings.js +15 -0
- package/frontend/src/lib/api/users.js +52 -0
- package/frontend/src/lib/components/layout/Nav.svelte +116 -4
- package/frontend/src/lib/components/layout/RunnerPanel.svelte +321 -31
- package/frontend/src/lib/components/ui/Modal.svelte +8 -1
- package/frontend/src/lib/constants.js +2 -0
- package/frontend/src/lib/stores/auth.js +60 -0
- package/frontend/src/lib/stores/runner.js +11 -2
- package/frontend/src/routes/+layout.svelte +32 -4
- package/frontend/src/routes/+page.svelte +1 -1
- package/frontend/src/routes/login/+page.svelte +209 -0
- package/frontend/src/routes/scheduled-tests/+page.svelte +65 -7
- package/frontend/src/routes/settings/+page.svelte +677 -6
- package/frontend/src/routes/setup/+page.svelte +249 -0
- package/frontend/src/routes/test-repository/+page.svelte +1379 -0
- package/frontend/src/routes/test-repository/runs/[id]/+page.svelte +1549 -0
- package/frontend/src/routes/test-repository/suites/[id]/+page.svelte +1490 -0
- package/package.json +1 -1
|
@@ -0,0 +1,191 @@
|
|
|
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 suiteSelect = {
|
|
21
|
+
id: true,
|
|
22
|
+
displayId: true,
|
|
23
|
+
name: true,
|
|
24
|
+
description: true,
|
|
25
|
+
priority: true,
|
|
26
|
+
createdAt: true,
|
|
27
|
+
updatedAt: true,
|
|
28
|
+
createdBy: { select: { id: true, name: true } },
|
|
29
|
+
_count: { select: { cases: true } }
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function suiteOrderBy(sortBy, sortOrder) {
|
|
33
|
+
const dir = sortOrder === 'desc' ? 'desc' : 'asc';
|
|
34
|
+
if (sortBy === 'displayId') return { displayId: dir };
|
|
35
|
+
if (sortBy === 'name') return { name: dir };
|
|
36
|
+
return { createdAt: dir };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function getAll({ page = 1, limit = 20, sortBy = 'createdAt', sortOrder = 'desc' } = {}) {
|
|
40
|
+
const skip = (page - 1) * limit;
|
|
41
|
+
const orderBy = suiteOrderBy(sortBy, sortOrder);
|
|
42
|
+
const [suites, total] = await Promise.all([
|
|
43
|
+
prisma.testSuite.findMany({ select: suiteSelect, orderBy, skip, take: limit }),
|
|
44
|
+
prisma.testSuite.count()
|
|
45
|
+
]);
|
|
46
|
+
return { suites, total };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function search(q) {
|
|
50
|
+
const [suites, cases] = await Promise.all([
|
|
51
|
+
prisma.testSuite.findMany({
|
|
52
|
+
where: {
|
|
53
|
+
OR: [
|
|
54
|
+
{ displayId: { contains: q, mode: 'insensitive' } },
|
|
55
|
+
{ name: { contains: q, mode: 'insensitive' } }
|
|
56
|
+
]
|
|
57
|
+
},
|
|
58
|
+
select: suiteSelect,
|
|
59
|
+
orderBy: { createdAt: 'asc' }
|
|
60
|
+
}),
|
|
61
|
+
prisma.testCase.findMany({
|
|
62
|
+
where: {
|
|
63
|
+
OR: [
|
|
64
|
+
{ displayId: { contains: q, mode: 'insensitive' } },
|
|
65
|
+
{ title: { contains: q, mode: 'insensitive' } }
|
|
66
|
+
]
|
|
67
|
+
},
|
|
68
|
+
select: {
|
|
69
|
+
id: true,
|
|
70
|
+
displayId: true,
|
|
71
|
+
title: true,
|
|
72
|
+
priority: true,
|
|
73
|
+
isAutomated: true,
|
|
74
|
+
suite: { select: { id: true, displayId: true, name: true } }
|
|
75
|
+
},
|
|
76
|
+
orderBy: { createdAt: 'asc' }
|
|
77
|
+
})
|
|
78
|
+
]);
|
|
79
|
+
return { suites, cases };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function getAllWithCases() {
|
|
83
|
+
return prisma.testSuite.findMany({
|
|
84
|
+
select: {
|
|
85
|
+
...suiteSelect,
|
|
86
|
+
cases: {
|
|
87
|
+
select: {
|
|
88
|
+
id: true,
|
|
89
|
+
displayId: true,
|
|
90
|
+
title: true,
|
|
91
|
+
priority: true,
|
|
92
|
+
isAutomated: true
|
|
93
|
+
},
|
|
94
|
+
orderBy: { createdAt: 'asc' }
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
orderBy: { createdAt: 'asc' }
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function getById(id) {
|
|
102
|
+
return prisma.testSuite.findUnique({
|
|
103
|
+
where: { id },
|
|
104
|
+
select: {
|
|
105
|
+
...suiteSelect,
|
|
106
|
+
cases: {
|
|
107
|
+
select: {
|
|
108
|
+
id: true,
|
|
109
|
+
displayId: true,
|
|
110
|
+
title: true,
|
|
111
|
+
priority: true,
|
|
112
|
+
isAutomated: true,
|
|
113
|
+
createdAt: true,
|
|
114
|
+
createdBy: { select: { id: true, name: true } },
|
|
115
|
+
_count: { select: { steps: true } }
|
|
116
|
+
},
|
|
117
|
+
orderBy: { createdAt: 'asc' }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function create({ name, description, priority, createdById }) {
|
|
124
|
+
const project = await prisma.project.upsert({
|
|
125
|
+
where: { id: 1 },
|
|
126
|
+
create: { id: 1, suiteSeqNext: 1 },
|
|
127
|
+
update: { suiteSeqNext: { increment: 1 } },
|
|
128
|
+
select: { suiteSeqNext: true, testSuitePrefix: true }
|
|
129
|
+
});
|
|
130
|
+
const num = String(project.suiteSeqNext).padStart(3, '0');
|
|
131
|
+
const displayId = `${project.testSuitePrefix}-${num}`;
|
|
132
|
+
|
|
133
|
+
return prisma.testSuite.create({
|
|
134
|
+
data: {
|
|
135
|
+
displayId,
|
|
136
|
+
name,
|
|
137
|
+
description: description ?? '',
|
|
138
|
+
priority: priority ?? 'Medium',
|
|
139
|
+
createdById
|
|
140
|
+
},
|
|
141
|
+
select: suiteSelect
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function update(id, { name, description, priority }) {
|
|
146
|
+
return prisma.testSuite.update({
|
|
147
|
+
where: { id },
|
|
148
|
+
data: {
|
|
149
|
+
...(name !== undefined && { name }),
|
|
150
|
+
...(description !== undefined && { description }),
|
|
151
|
+
...(priority !== undefined && { priority })
|
|
152
|
+
},
|
|
153
|
+
select: suiteSelect
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function remove(id) {
|
|
158
|
+
return prisma.testSuite.delete({ where: { id } });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function migratePrefix(newPrefix) {
|
|
162
|
+
const suites = await prisma.testSuite.findMany({
|
|
163
|
+
select: { id: true, displayId: true },
|
|
164
|
+
orderBy: { createdAt: 'asc' }
|
|
165
|
+
});
|
|
166
|
+
const project = await prisma.project.upsert({
|
|
167
|
+
where: { id: 1 },
|
|
168
|
+
create: { id: 1 },
|
|
169
|
+
update: { testSuitePrefix: newPrefix },
|
|
170
|
+
select: { testSuitePrefix: true }
|
|
171
|
+
});
|
|
172
|
+
for (let i = 0; i < suites.length; i++) {
|
|
173
|
+
const num = String(i + 1).padStart(3, '0');
|
|
174
|
+
await prisma.testSuite.update({
|
|
175
|
+
where: { id: suites[i].id },
|
|
176
|
+
data: { displayId: `${newPrefix}-${num}` }
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return project;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = {
|
|
183
|
+
getAll,
|
|
184
|
+
search,
|
|
185
|
+
getAllWithCases,
|
|
186
|
+
getById,
|
|
187
|
+
create,
|
|
188
|
+
update,
|
|
189
|
+
remove,
|
|
190
|
+
migratePrefix
|
|
191
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
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 bcrypt = require('bcryptjs');
|
|
19
|
+
const jwt = require('jsonwebtoken');
|
|
20
|
+
const prisma = require('./prisma');
|
|
21
|
+
|
|
22
|
+
const JWT_SECRET = process.env.JWT_SECRET || 'plum-dev-secret-change-in-production';
|
|
23
|
+
const JWT_EXPIRY = '7d';
|
|
24
|
+
const SALT_ROUNDS = 10;
|
|
25
|
+
|
|
26
|
+
const userSelect = { id: true, name: true, email: true, role: true, createdAt: true };
|
|
27
|
+
|
|
28
|
+
async function needsSetup() {
|
|
29
|
+
const count = await prisma.user.count();
|
|
30
|
+
return count === 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function createUser({ name, email, password, role = 'user' }) {
|
|
34
|
+
const hashed = await bcrypt.hash(password, SALT_ROUNDS);
|
|
35
|
+
return prisma.user.create({
|
|
36
|
+
data: { name, email, password: hashed, role },
|
|
37
|
+
select: userSelect
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function login({ email, password }) {
|
|
42
|
+
const user = await prisma.user.findUnique({ where: { email } });
|
|
43
|
+
if (!user) return null;
|
|
44
|
+
const match = await bcrypt.compare(password, user.password);
|
|
45
|
+
if (!match) return null;
|
|
46
|
+
const token = jwt.sign(
|
|
47
|
+
{ userId: user.id, email: user.email, name: user.name, role: user.role },
|
|
48
|
+
JWT_SECRET,
|
|
49
|
+
{ expiresIn: JWT_EXPIRY }
|
|
50
|
+
);
|
|
51
|
+
return { token, user: { id: user.id, name: user.name, email: user.email, role: user.role } };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function verifyToken(token) {
|
|
55
|
+
return jwt.verify(token, JWT_SECRET);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function getAll() {
|
|
59
|
+
return prisma.user.findMany({ select: userSelect, orderBy: { createdAt: 'asc' } });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function getMembers() {
|
|
63
|
+
return prisma.user.findMany({
|
|
64
|
+
select: { id: true, name: true },
|
|
65
|
+
orderBy: { name: 'asc' }
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function getById(id) {
|
|
70
|
+
return prisma.user.findUnique({ where: { id }, select: userSelect });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function updateProfile(id, { name, email }) {
|
|
74
|
+
if (email) {
|
|
75
|
+
const conflict = await prisma.user.findFirst({ where: { email, NOT: { id } } });
|
|
76
|
+
if (conflict) return { ok: false, error: 'Email already in use' };
|
|
77
|
+
}
|
|
78
|
+
const user = await prisma.user.update({
|
|
79
|
+
where: { id },
|
|
80
|
+
data: {
|
|
81
|
+
...(name !== undefined && { name }),
|
|
82
|
+
...(email !== undefined && { email })
|
|
83
|
+
},
|
|
84
|
+
select: userSelect
|
|
85
|
+
});
|
|
86
|
+
return { ok: true, user };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function updatePassword(id, { currentPassword, newPassword }) {
|
|
90
|
+
const user = await prisma.user.findUnique({ where: { id } });
|
|
91
|
+
if (!user) return { ok: false, error: 'User not found' };
|
|
92
|
+
const match = await bcrypt.compare(currentPassword, user.password);
|
|
93
|
+
if (!match) return { ok: false, error: 'Current password is incorrect' };
|
|
94
|
+
const hashed = await bcrypt.hash(newPassword, SALT_ROUNDS);
|
|
95
|
+
await prisma.user.update({ where: { id }, data: { password: hashed } });
|
|
96
|
+
return { ok: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function deleteUser(id) {
|
|
100
|
+
return prisma.user.delete({ where: { id } });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = {
|
|
104
|
+
needsSetup,
|
|
105
|
+
createUser,
|
|
106
|
+
login,
|
|
107
|
+
verifyToken,
|
|
108
|
+
getAll,
|
|
109
|
+
getMembers,
|
|
110
|
+
getById,
|
|
111
|
+
updateProfile,
|
|
112
|
+
updatePassword,
|
|
113
|
+
deleteUser
|
|
114
|
+
};
|
|
@@ -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,12 +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;
|
|
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];
|
|
41
|
+
testRunId = null;
|
|
42
|
+
notifyDiscord = false;
|
|
43
|
+
notifySlack = false;
|
|
39
44
|
} else {
|
|
40
45
|
tag = payload.tag ?? '';
|
|
41
46
|
workers = Number(payload.workers) > 1 ? Number(payload.workers) : 1;
|
|
@@ -44,6 +49,9 @@ const socketHandler = (io) => {
|
|
|
44
49
|
Array.isArray(payload.runners) && payload.runners.length > 0
|
|
45
50
|
? payload.runners
|
|
46
51
|
: [BUILT_IN_RUNNER_ID];
|
|
52
|
+
testRunId = payload.testRunId ?? null;
|
|
53
|
+
notifyDiscord = payload.notifyDiscord === true;
|
|
54
|
+
notifySlack = payload.notifySlack === true;
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
// Drop runner ids that no longer exist (e.g. a deleted runner still
|
|
@@ -61,9 +69,30 @@ const socketHandler = (io) => {
|
|
|
61
69
|
const isSingleBuiltIn = runners.length === 1 && runners[0] === BUILT_IN_RUNNER_ID;
|
|
62
70
|
|
|
63
71
|
if (isSingleBuiltIn) {
|
|
64
|
-
runBuiltIn(
|
|
72
|
+
runBuiltIn(
|
|
73
|
+
io,
|
|
74
|
+
socket,
|
|
75
|
+
activeProcs,
|
|
76
|
+
tag,
|
|
77
|
+
workers,
|
|
78
|
+
browser,
|
|
79
|
+
testRunId,
|
|
80
|
+
notifyDiscord,
|
|
81
|
+
notifySlack
|
|
82
|
+
);
|
|
65
83
|
} else {
|
|
66
|
-
runDistributed(
|
|
84
|
+
runDistributed(
|
|
85
|
+
io,
|
|
86
|
+
socket,
|
|
87
|
+
activeProcs,
|
|
88
|
+
tag,
|
|
89
|
+
workers,
|
|
90
|
+
browser,
|
|
91
|
+
runners,
|
|
92
|
+
testRunId,
|
|
93
|
+
notifyDiscord,
|
|
94
|
+
notifySlack
|
|
95
|
+
);
|
|
67
96
|
}
|
|
68
97
|
});
|
|
69
98
|
|
|
@@ -85,7 +114,17 @@ const socketHandler = (io) => {
|
|
|
85
114
|
// Single built-in runner
|
|
86
115
|
// ---------------------------------------------------------------------------
|
|
87
116
|
|
|
88
|
-
function runBuiltIn(
|
|
117
|
+
function runBuiltIn(
|
|
118
|
+
io,
|
|
119
|
+
socket,
|
|
120
|
+
activeProcs,
|
|
121
|
+
tag,
|
|
122
|
+
workers,
|
|
123
|
+
browser,
|
|
124
|
+
testRunId,
|
|
125
|
+
notifyDiscord,
|
|
126
|
+
notifySlack
|
|
127
|
+
) {
|
|
89
128
|
const env = {
|
|
90
129
|
...process.env,
|
|
91
130
|
TAG: tag,
|
|
@@ -94,6 +133,7 @@ function runBuiltIn(io, socket, activeProcs, tag, workers, browser) {
|
|
|
94
133
|
BROWSER: browser
|
|
95
134
|
};
|
|
96
135
|
if (workers > 1) env.PARALLEL = String(workers);
|
|
136
|
+
if (testRunId) env.TEST_RUN_ID = testRunId;
|
|
97
137
|
|
|
98
138
|
const proc = spawn('npm', ['run', 'test'], { env, shell: true });
|
|
99
139
|
activeProcs.add(proc);
|
|
@@ -105,8 +145,30 @@ function runBuiltIn(io, socket, activeProcs, tag, workers, browser) {
|
|
|
105
145
|
activeProcs.delete(proc);
|
|
106
146
|
socket.emit('log', `\nTest finished with code ${code}`);
|
|
107
147
|
socket.emit('done', code);
|
|
108
|
-
// Notify all connected clients that a new report is available
|
|
109
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
|
+
}
|
|
110
172
|
});
|
|
111
173
|
}
|
|
112
174
|
|
|
@@ -114,7 +176,18 @@ function runBuiltIn(io, socket, activeProcs, tag, workers, browser) {
|
|
|
114
176
|
// Distributed (multi-runner) path
|
|
115
177
|
// ---------------------------------------------------------------------------
|
|
116
178
|
|
|
117
|
-
async function runDistributed(
|
|
179
|
+
async function runDistributed(
|
|
180
|
+
io,
|
|
181
|
+
socket,
|
|
182
|
+
activeProcs,
|
|
183
|
+
tag,
|
|
184
|
+
workers,
|
|
185
|
+
browser,
|
|
186
|
+
runnerIds,
|
|
187
|
+
testRunId,
|
|
188
|
+
notifyDiscord,
|
|
189
|
+
notifySlack
|
|
190
|
+
) {
|
|
118
191
|
const allIds = getTestIdsForTag(tag);
|
|
119
192
|
const chunks = chunkTests(allIds, runnerIds.length);
|
|
120
193
|
|
|
@@ -170,7 +243,8 @@ async function runDistributed(io, socket, activeProcs, tag, workers, browser, ru
|
|
|
170
243
|
overallCode,
|
|
171
244
|
tag,
|
|
172
245
|
triggerType: TRIGGER_TYPE.MANUAL,
|
|
173
|
-
browser
|
|
246
|
+
browser,
|
|
247
|
+
testRunId: testRunId ?? null
|
|
174
248
|
})
|
|
175
249
|
.then((saved) => {
|
|
176
250
|
// Result is authoritative from the merged report, not the exit code —
|
|
@@ -178,6 +252,21 @@ async function runDistributed(io, socket, activeProcs, tag, workers, browser, ru
|
|
|
178
252
|
// a passing run to "fail" in the live UI.
|
|
179
253
|
socket.emit('done', { code: saved.status === 'PASS' ? 0 : 1, reportId: saved.id });
|
|
180
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
|
+
}
|
|
181
270
|
})
|
|
182
271
|
.catch((e) => {
|
|
183
272
|
console.error('[runner] Failed to save combined report:', e.message);
|
package/bin/plum.js
CHANGED
|
@@ -279,8 +279,75 @@ async function serverStart() {
|
|
|
279
279
|
applyServerConfig(cfg);
|
|
280
280
|
clack.log.info(`UI: ${pc.cyan(`http://localhost:${cfg.frontendPort}`)}`);
|
|
281
281
|
clack.log.info(`Nodes register against: ${pc.dim(cfg.primaryPublicUrl)}`);
|
|
282
|
-
|
|
283
|
-
|
|
282
|
+
|
|
283
|
+
execSync('docker compose up --build -d', { cwd: plumRoot, stdio: 'inherit' });
|
|
284
|
+
|
|
285
|
+
const apiBase = `http://localhost:${cfg.backendPort}`;
|
|
286
|
+
const s = clack.spinner();
|
|
287
|
+
s.start('Waiting for server to be ready…');
|
|
288
|
+
let ready = false;
|
|
289
|
+
for (let i = 0; i < 40; i++) {
|
|
290
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
291
|
+
try {
|
|
292
|
+
const res = await fetch(`${apiBase}/auth/needs-setup`);
|
|
293
|
+
if (res.ok) {
|
|
294
|
+
ready = true;
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
} catch {}
|
|
298
|
+
}
|
|
299
|
+
s.stop(ready ? pc.green('✓ Server is ready') : pc.yellow('Server may still be starting'));
|
|
300
|
+
|
|
301
|
+
if (ready) {
|
|
302
|
+
let needsSetup = false;
|
|
303
|
+
try {
|
|
304
|
+
const res = await fetch(`${apiBase}/auth/needs-setup`);
|
|
305
|
+
const data = await res.json();
|
|
306
|
+
needsSetup = data.needsSetup;
|
|
307
|
+
} catch {}
|
|
308
|
+
|
|
309
|
+
if (needsSetup) {
|
|
310
|
+
clack.log.info('No users found — create your first account to get started.');
|
|
311
|
+
|
|
312
|
+
const name = await clack.text({ message: 'Your name', placeholder: 'Jane Smith' });
|
|
313
|
+
if (clack.isCancel(name)) {
|
|
314
|
+
clack.log.warn('Skipped. Create a user at /setup in the UI.');
|
|
315
|
+
} else {
|
|
316
|
+
const email = await clack.text({
|
|
317
|
+
message: 'Email address',
|
|
318
|
+
placeholder: 'jane@example.com'
|
|
319
|
+
});
|
|
320
|
+
if (clack.isCancel(email)) {
|
|
321
|
+
clack.log.warn('Skipped. Create a user at /setup in the UI.');
|
|
322
|
+
} else {
|
|
323
|
+
const password = await clack.password({ message: 'Password (min 8 characters)' });
|
|
324
|
+
if (clack.isCancel(password)) {
|
|
325
|
+
clack.log.warn('Skipped. Create a user at /setup in the UI.');
|
|
326
|
+
} else {
|
|
327
|
+
try {
|
|
328
|
+
const res = await fetch(`${apiBase}/auth/setup`, {
|
|
329
|
+
method: 'POST',
|
|
330
|
+
headers: { 'Content-Type': 'application/json' },
|
|
331
|
+
body: JSON.stringify({ name, email, password })
|
|
332
|
+
});
|
|
333
|
+
if (res.ok) {
|
|
334
|
+
clack.log.success(`Account created for ${email}. You can now log in.`);
|
|
335
|
+
} else {
|
|
336
|
+
const err = await res.json();
|
|
337
|
+
clack.log.error(`Failed to create account: ${err.error ?? 'unknown error'}`);
|
|
338
|
+
}
|
|
339
|
+
} catch (e) {
|
|
340
|
+
clack.log.error(`Failed to create account: ${e.message}`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
clack.log.info(pc.dim('Streaming logs (Ctrl+C to detach — server keeps running):'));
|
|
349
|
+
execSync('docker compose logs -f', { cwd: plumRoot, stdio: 'inherit' });
|
|
350
|
+
clack.outro(pc.dim('Detached from logs. Run "docker compose down" to stop.'));
|
|
284
351
|
}
|
|
285
352
|
|
|
286
353
|
async function serverReconfig() {
|
|
@@ -588,7 +655,7 @@ switch (command) {
|
|
|
588
655
|
const readmeContent = [
|
|
589
656
|
'# My Tests',
|
|
590
657
|
'',
|
|
591
|
-
'Powered by [Plum](https://github.com/silverlunah/plum) — Playwright + Cucumber.',
|
|
658
|
+
'Powered by [Plum](https://github.com/silverlunah/plum) — Playwright + Cucumber + Test Repository.',
|
|
592
659
|
'',
|
|
593
660
|
'## Getting Started',
|
|
594
661
|
'',
|
|
@@ -599,15 +666,16 @@ switch (command) {
|
|
|
599
666
|
' ```bash',
|
|
600
667
|
' plum run-test',
|
|
601
668
|
' ```',
|
|
602
|
-
'3. **Write your first test** —
|
|
669
|
+
'3. **Write your first test** — scaffold a full feature or generate a single step:',
|
|
603
670
|
' ```bash',
|
|
604
|
-
' plum create-
|
|
671
|
+
' plum create-test # scaffold .feature + Page.ts + Steps.ts',
|
|
672
|
+
' plum create-step # add a single step to an existing file',
|
|
605
673
|
' ```',
|
|
606
|
-
'4. **Start the full UI** (requires Docker) to trigger tests
|
|
674
|
+
'4. **Start the full UI** (requires Docker) to trigger tests, view reports, and manage your test repository:',
|
|
607
675
|
' ```bash',
|
|
608
676
|
' plum start',
|
|
609
677
|
' ```',
|
|
610
|
-
' Then open **http://localhost:5173
|
|
678
|
+
' On first run, Plum asks you to create an admin account. Then open **http://localhost:5173** and sign in.',
|
|
611
679
|
'',
|
|
612
680
|
'---',
|
|
613
681
|
'',
|
|
@@ -667,6 +735,19 @@ switch (command) {
|
|
|
667
735
|
'',
|
|
668
736
|
'---',
|
|
669
737
|
'',
|
|
738
|
+
'## Test Repository',
|
|
739
|
+
'',
|
|
740
|
+
'Plum includes a built-in test case management system. Access it from the **Test Repository** tab in the UI.',
|
|
741
|
+
'',
|
|
742
|
+
'- **Test Suites** — Group related test cases. Each suite gets an auto-assigned ID (e.g. `TS-001`).',
|
|
743
|
+
'- **Test Cases** — Document steps (Action / Test Data / Expected Output), set priority, and assign a Cucumber `@tag` to link automation.',
|
|
744
|
+
'- **Test Runs** — Build a run from any combination of cases, execute them one by one (pass/fail/blocked/skip), and track history.',
|
|
745
|
+
'- **Auto-linking** — When a build completes, Plum matches Cucumber scenario tags against `automatedTag` values on your test cases and marks them as automated.',
|
|
746
|
+
'',
|
|
747
|
+
'To link a test case to automation, set its **Automated tag** (e.g. `test-login-1`) to match the `@tag` on the Cucumber scenario.',
|
|
748
|
+
'',
|
|
749
|
+
'---',
|
|
750
|
+
'',
|
|
670
751
|
'## Cucumber & Gherkin Resources',
|
|
671
752
|
'',
|
|
672
753
|
'New to Cucumber? These links will get you up to speed quickly:',
|
|
@@ -700,8 +781,9 @@ switch (command) {
|
|
|
700
781
|
`${pc.bold('Start the full UI')} ${pc.dim('(requires Docker)')}`,
|
|
701
782
|
` ${pc.cyan('plum server start')}`,
|
|
702
783
|
'',
|
|
703
|
-
`${pc.bold('Generate
|
|
704
|
-
` ${pc.cyan('plum create-
|
|
784
|
+
`${pc.bold('Generate tests')}`,
|
|
785
|
+
` ${pc.cyan('plum create-test')} scaffold a new feature`,
|
|
786
|
+
` ${pc.cyan('plum create-step')} add a step definition`
|
|
705
787
|
].join('\n'),
|
|
706
788
|
'Next steps'
|
|
707
789
|
);
|
|
@@ -885,6 +967,19 @@ switch (command) {
|
|
|
885
967
|
break;
|
|
886
968
|
}
|
|
887
969
|
|
|
970
|
+
case 'create-test': {
|
|
971
|
+
const createTestScript = path.join(plumRoot, 'backend', 'config', 'scripts', 'create-test.mjs');
|
|
972
|
+
execSync(`node "${createTestScript}"`, {
|
|
973
|
+
cwd: process.cwd(),
|
|
974
|
+
stdio: 'inherit',
|
|
975
|
+
env: {
|
|
976
|
+
...process.env,
|
|
977
|
+
TESTS_ROOT: userTestsPath
|
|
978
|
+
}
|
|
979
|
+
});
|
|
980
|
+
break;
|
|
981
|
+
}
|
|
982
|
+
|
|
888
983
|
default:
|
|
889
984
|
console.log('--------------------------------------\n');
|
|
890
985
|
console.log('Usage: plum <command>\n');
|
|
@@ -918,5 +1013,6 @@ switch (command) {
|
|
|
918
1013
|
console.log(' --parallel <n> Run across n parallel workers');
|
|
919
1014
|
console.log(' --browser <name> chromium | firefox (default: chromium)');
|
|
920
1015
|
console.log(' create-step Interactively scaffold a new step definition');
|
|
1016
|
+
console.log(' create-test Scaffold a new .feature + Page.ts + Steps.ts');
|
|
921
1017
|
console.log('\n--------------------------------------\n');
|
|
922
1018
|
}
|