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,122 @@
|
|
|
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 express = require('express');
|
|
19
|
+
const router = express.Router();
|
|
20
|
+
const { jwtAuth } = require('../middleware/jwtAuth');
|
|
21
|
+
const testRunService = require('../services/testRunService');
|
|
22
|
+
|
|
23
|
+
router.get('/', jwtAuth, async (req, res, next) => {
|
|
24
|
+
try {
|
|
25
|
+
const page = Math.max(1, parseInt(req.query.page) || 1);
|
|
26
|
+
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit) || 20));
|
|
27
|
+
const { q, sortBy, sortOrder } = req.query;
|
|
28
|
+
const result = await testRunService.getAll({ page, limit, q, sortBy, sortOrder });
|
|
29
|
+
res.json(result);
|
|
30
|
+
} catch (e) {
|
|
31
|
+
next(e);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
router.get('/:id', jwtAuth, async (req, res, next) => {
|
|
36
|
+
try {
|
|
37
|
+
const run = await testRunService.getById(req.params.id);
|
|
38
|
+
if (!run) return res.status(404).json({ error: 'Test run not found' });
|
|
39
|
+
res.json({ run });
|
|
40
|
+
} catch (e) {
|
|
41
|
+
next(e);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
router.post('/', jwtAuth, async (req, res, next) => {
|
|
46
|
+
try {
|
|
47
|
+
const { title, caseIds } = req.body;
|
|
48
|
+
if (!title) return res.status(400).json({ error: 'title is required' });
|
|
49
|
+
const run = await testRunService.create({ title, caseIds, createdById: req.user.userId });
|
|
50
|
+
res.status(201).json({ run });
|
|
51
|
+
} catch (e) {
|
|
52
|
+
next(e);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
router.put('/:id', jwtAuth, async (req, res, next) => {
|
|
57
|
+
try {
|
|
58
|
+
const { title, status, caseIds } = req.body;
|
|
59
|
+
const run = await testRunService.update(req.params.id, { title, status, caseIds });
|
|
60
|
+
res.json({ run });
|
|
61
|
+
} catch (e) {
|
|
62
|
+
next(e);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
router.post('/:id/duplicate', jwtAuth, async (req, res, next) => {
|
|
67
|
+
try {
|
|
68
|
+
const run = await testRunService.duplicate(req.params.id, { createdById: req.user.userId });
|
|
69
|
+
if (!run) return res.status(404).json({ error: 'Test run not found' });
|
|
70
|
+
res.status(201).json({ run });
|
|
71
|
+
} catch (e) {
|
|
72
|
+
next(e);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
router.delete('/:id', jwtAuth, async (req, res, next) => {
|
|
77
|
+
try {
|
|
78
|
+
await testRunService.remove(req.params.id);
|
|
79
|
+
res.json({ ok: true });
|
|
80
|
+
} catch (e) {
|
|
81
|
+
next(e);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
router.put('/entries/:entryId/assign', jwtAuth, async (req, res, next) => {
|
|
86
|
+
try {
|
|
87
|
+
const { userId } = req.body;
|
|
88
|
+
const entry = await testRunService.assignEntry(req.params.entryId, { userId: userId ?? null });
|
|
89
|
+
res.json({ entry });
|
|
90
|
+
} catch (e) {
|
|
91
|
+
next(e);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
router.post('/entries/:entryId/result', jwtAuth, async (req, res, next) => {
|
|
96
|
+
try {
|
|
97
|
+
const { status, notes } = req.body;
|
|
98
|
+
if (!status) return res.status(400).json({ error: 'status is required' });
|
|
99
|
+
const entry = await testRunService.updateEntry(req.params.entryId, {
|
|
100
|
+
status,
|
|
101
|
+
notes,
|
|
102
|
+
executedById: req.user.userId
|
|
103
|
+
});
|
|
104
|
+
res.json({ entry });
|
|
105
|
+
} catch (e) {
|
|
106
|
+
next(e);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
router.post('/:id/reorder', jwtAuth, async (req, res, next) => {
|
|
111
|
+
try {
|
|
112
|
+
const { entryIds } = req.body;
|
|
113
|
+
if (!Array.isArray(entryIds))
|
|
114
|
+
return res.status(400).json({ error: 'entryIds must be an array' });
|
|
115
|
+
await testRunService.reorderEntries(req.params.id, entryIds);
|
|
116
|
+
res.json({ ok: true });
|
|
117
|
+
} catch (e) {
|
|
118
|
+
next(e);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
module.exports = router;
|
|
@@ -0,0 +1,92 @@
|
|
|
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 express = require('express');
|
|
19
|
+
const router = express.Router();
|
|
20
|
+
const { jwtAuth } = require('../middleware/jwtAuth');
|
|
21
|
+
const testSuiteService = require('../services/testSuiteService');
|
|
22
|
+
|
|
23
|
+
router.get('/', jwtAuth, async (req, res, next) => {
|
|
24
|
+
try {
|
|
25
|
+
if (req.query.withCases === 'true') {
|
|
26
|
+
const suites = await testSuiteService.getAllWithCases();
|
|
27
|
+
return res.json({ suites });
|
|
28
|
+
}
|
|
29
|
+
if (req.query.q) {
|
|
30
|
+
const results = await testSuiteService.search(req.query.q);
|
|
31
|
+
return res.json(results);
|
|
32
|
+
}
|
|
33
|
+
const page = Math.max(1, parseInt(req.query.page) || 1);
|
|
34
|
+
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit) || 20));
|
|
35
|
+
const { sortBy, sortOrder } = req.query;
|
|
36
|
+
const result = await testSuiteService.getAll({ page, limit, sortBy, sortOrder });
|
|
37
|
+
res.json(result);
|
|
38
|
+
} catch (e) {
|
|
39
|
+
next(e);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
router.get('/:id', jwtAuth, async (req, res, next) => {
|
|
44
|
+
try {
|
|
45
|
+
const suite = await testSuiteService.getById(req.params.id);
|
|
46
|
+
if (!suite) return res.status(404).json({ error: 'Suite not found' });
|
|
47
|
+
res.json({ suite });
|
|
48
|
+
} catch (e) {
|
|
49
|
+
next(e);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
router.post('/', jwtAuth, async (req, res, next) => {
|
|
54
|
+
try {
|
|
55
|
+
const { name, description, priority } = req.body;
|
|
56
|
+
if (!name) return res.status(400).json({ error: 'name is required' });
|
|
57
|
+
const suite = await testSuiteService.create({
|
|
58
|
+
name,
|
|
59
|
+
description,
|
|
60
|
+
priority,
|
|
61
|
+
createdById: req.user.userId
|
|
62
|
+
});
|
|
63
|
+
res.status(201).json({ suite });
|
|
64
|
+
} catch (e) {
|
|
65
|
+
next(e);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
router.put('/:id', jwtAuth, async (req, res, next) => {
|
|
70
|
+
try {
|
|
71
|
+
const { name, description, priority } = req.body;
|
|
72
|
+
const suite = await testSuiteService.update(req.params.id, {
|
|
73
|
+
name,
|
|
74
|
+
description,
|
|
75
|
+
priority
|
|
76
|
+
});
|
|
77
|
+
res.json({ suite });
|
|
78
|
+
} catch (e) {
|
|
79
|
+
next(e);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
router.delete('/:id', jwtAuth, async (req, res, next) => {
|
|
84
|
+
try {
|
|
85
|
+
await testSuiteService.remove(req.params.id);
|
|
86
|
+
res.json({ ok: true });
|
|
87
|
+
} catch (e) {
|
|
88
|
+
next(e);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
module.exports = router;
|
|
@@ -0,0 +1,67 @@
|
|
|
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 express = require('express');
|
|
19
|
+
const router = express.Router();
|
|
20
|
+
const { jwtAuth } = require('../middleware/jwtAuth');
|
|
21
|
+
const { requireAdmin } = require('../middleware/requireAdmin');
|
|
22
|
+
const userService = require('../services/userService');
|
|
23
|
+
|
|
24
|
+
router.get('/members', jwtAuth, async (req, res, next) => {
|
|
25
|
+
try {
|
|
26
|
+
const users = await userService.getMembers();
|
|
27
|
+
res.json({ users });
|
|
28
|
+
} catch (e) {
|
|
29
|
+
next(e);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
router.get('/', jwtAuth, requireAdmin, async (req, res, next) => {
|
|
34
|
+
try {
|
|
35
|
+
const users = await userService.getAll();
|
|
36
|
+
res.json({ users });
|
|
37
|
+
} catch (e) {
|
|
38
|
+
next(e);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
router.post('/', jwtAuth, requireAdmin, async (req, res, next) => {
|
|
43
|
+
try {
|
|
44
|
+
const { name, email, password, role = 'user' } = req.body;
|
|
45
|
+
if (!name || !email || !password)
|
|
46
|
+
return res.status(400).json({ error: 'name, email and password are required' });
|
|
47
|
+
if (!['admin', 'user'].includes(role))
|
|
48
|
+
return res.status(400).json({ error: 'role must be admin or user' });
|
|
49
|
+
const user = await userService.createUser({ name, email, password, role });
|
|
50
|
+
res.status(201).json({ user });
|
|
51
|
+
} catch (e) {
|
|
52
|
+
next(e);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
router.delete('/:id', jwtAuth, requireAdmin, async (req, res, next) => {
|
|
57
|
+
try {
|
|
58
|
+
if (req.params.id === req.user.userId)
|
|
59
|
+
return res.status(400).json({ error: 'Cannot delete your own account' });
|
|
60
|
+
await userService.deleteUser(req.params.id);
|
|
61
|
+
res.json({ ok: true });
|
|
62
|
+
} catch (e) {
|
|
63
|
+
next(e);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
module.exports = router;
|
|
@@ -64,12 +64,13 @@ async function main() {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
const pascal = toPascalCase(rawName);
|
|
67
|
+
const base = pascal.endsWith('Page') ? pascal.slice(0, -4) : pascal;
|
|
67
68
|
const kebab = toKebabCase(pascal);
|
|
68
69
|
const suiteTag = `@suite-${kebab}`;
|
|
69
70
|
const testTag = `@test-${kebab}-1`;
|
|
70
71
|
|
|
71
72
|
const featurePath = path.join(testsRoot, 'features', `${pascal}.feature`);
|
|
72
|
-
const pagePath = path.join(testsRoot, 'pages', `${
|
|
73
|
+
const pagePath = path.join(testsRoot, 'pages', `${base}Page.ts`);
|
|
73
74
|
const stepsPath = path.join(testsRoot, 'step_definitions', `${pascal}Steps.ts`);
|
|
74
75
|
|
|
75
76
|
const conflicts = [featurePath, pagePath, stepsPath].filter(fs.existsSync);
|
|
@@ -95,7 +96,7 @@ Feature: ${pascal}
|
|
|
95
96
|
|
|
96
97
|
const page = `import { page } from '../utils/browser';
|
|
97
98
|
|
|
98
|
-
export class ${
|
|
99
|
+
export class ${base}Page {
|
|
99
100
|
static async goTo() {
|
|
100
101
|
await page().goto(process.env.BASE_URL as string);
|
|
101
102
|
}
|
|
@@ -111,18 +112,18 @@ export class ${pascal}Page {
|
|
|
111
112
|
`;
|
|
112
113
|
|
|
113
114
|
const steps = `import { Given, When, Then } from '@cucumber/cucumber';
|
|
114
|
-
import { ${
|
|
115
|
+
import { ${base}Page } from '../pages/${base}Page';
|
|
115
116
|
|
|
116
117
|
Given('I am on the ${pascal} page', async () => {
|
|
117
|
-
await ${
|
|
118
|
+
await ${base}Page.goTo();
|
|
118
119
|
});
|
|
119
120
|
|
|
120
121
|
When('I perform an action', async () => {
|
|
121
|
-
await ${
|
|
122
|
+
await ${base}Page.performAction();
|
|
122
123
|
});
|
|
123
124
|
|
|
124
125
|
Then('I should see the expected result', async () => {
|
|
125
|
-
await ${
|
|
126
|
+
await ${base}Page.verifyResult();
|
|
126
127
|
});
|
|
127
128
|
`;
|
|
128
129
|
|
|
@@ -32,8 +32,16 @@ import pc from 'picocolors';
|
|
|
32
32
|
import runnerProcess from '../lib/runnerProcess.js';
|
|
33
33
|
import nodeRegister from '../lib/nodeRegister.js';
|
|
34
34
|
|
|
35
|
-
const {
|
|
36
|
-
|
|
35
|
+
const {
|
|
36
|
+
isLocalUrl,
|
|
37
|
+
parsePort,
|
|
38
|
+
pruneDead,
|
|
39
|
+
statusOf,
|
|
40
|
+
prepareEnv,
|
|
41
|
+
startNode,
|
|
42
|
+
stopNode,
|
|
43
|
+
findPidOnPort
|
|
44
|
+
} = runnerProcess;
|
|
37
45
|
const { generateToken, registerWithPrimary } = nodeRegister;
|
|
38
46
|
|
|
39
47
|
const API_URL = process.env.PLUM_API_URL || 'http://localhost:3001';
|
|
@@ -68,6 +76,9 @@ async function deleteRunner(id) {
|
|
|
68
76
|
/**
|
|
69
77
|
* Resolves the display + control state for every runner: reachability (ping),
|
|
70
78
|
* whether we own a live process for it, and whether we can control it at all.
|
|
79
|
+
*
|
|
80
|
+
* Local runners that are online but absent from the registry are automatically
|
|
81
|
+
* reclaimed by scanning their port for a running process.
|
|
71
82
|
*/
|
|
72
83
|
async function describeRunners() {
|
|
73
84
|
const runners = await fetchRunners();
|
|
@@ -77,7 +88,19 @@ async function describeRunners() {
|
|
|
77
88
|
runners.map(async (r) => {
|
|
78
89
|
const online = await pingRunner(r.id);
|
|
79
90
|
const local = isLocalUrl(r.url);
|
|
80
|
-
|
|
91
|
+
let managed = statusOf(r.id) === 'running';
|
|
92
|
+
|
|
93
|
+
if (local && online && !managed) {
|
|
94
|
+
const port = Number(parsePort(r.url));
|
|
95
|
+
const pid = findPidOnPort(port);
|
|
96
|
+
if (pid) {
|
|
97
|
+
const registry = runnerProcess.loadRegistry();
|
|
98
|
+
registry[r.id] = { pid, port: String(port), startedAt: Date.now() };
|
|
99
|
+
runnerProcess.saveRegistry(registry);
|
|
100
|
+
managed = true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
81
104
|
let state;
|
|
82
105
|
if (managed) state = 'managed';
|
|
83
106
|
else if (online) state = 'unmanaged';
|
|
@@ -181,7 +204,7 @@ async function runAction(r) {
|
|
|
181
204
|
if (cancelled(confirmed) || !confirmed) return;
|
|
182
205
|
const s = clack.spinner();
|
|
183
206
|
s.start(`Deleting "${r.name}"...`);
|
|
184
|
-
if (r.
|
|
207
|
+
if (r.local) stopNode(r.id, Number(parsePort(r.url)));
|
|
185
208
|
try {
|
|
186
209
|
await deleteRunner(r.id);
|
|
187
210
|
s.stop(pc.green(`Deleted "${r.name}"`));
|
|
@@ -194,7 +217,11 @@ async function runAction(r) {
|
|
|
194
217
|
async function addRunner() {
|
|
195
218
|
const suggested = `node-${generateToken().slice(0, 6)}`;
|
|
196
219
|
|
|
197
|
-
const name = await clack.text({
|
|
220
|
+
const name = await clack.text({
|
|
221
|
+
message: 'Runner name',
|
|
222
|
+
placeholder: suggested,
|
|
223
|
+
defaultValue: suggested
|
|
224
|
+
});
|
|
198
225
|
if (cancelled(name)) return;
|
|
199
226
|
|
|
200
227
|
const port = await clack.text({
|
|
@@ -205,7 +232,11 @@ async function addRunner() {
|
|
|
205
232
|
if (cancelled(port)) return;
|
|
206
233
|
|
|
207
234
|
const defToken = process.env.NODE_TOKEN || generateToken();
|
|
208
|
-
const token = await clack.text({
|
|
235
|
+
const token = await clack.text({
|
|
236
|
+
message: 'Auth token',
|
|
237
|
+
placeholder: defToken,
|
|
238
|
+
defaultValue: defToken
|
|
239
|
+
});
|
|
209
240
|
if (cancelled(token)) return;
|
|
210
241
|
|
|
211
242
|
// Dev nodes run as a bare process on the host; the dockerized primary reaches
|
|
@@ -216,9 +247,19 @@ async function addRunner() {
|
|
|
216
247
|
s.start(`Registering "${name}" with the primary...`);
|
|
217
248
|
let id;
|
|
218
249
|
try {
|
|
219
|
-
const res = await registerWithPrimary({
|
|
250
|
+
const res = await registerWithPrimary({
|
|
251
|
+
primary: API_URL,
|
|
252
|
+
name,
|
|
253
|
+
url,
|
|
254
|
+
token,
|
|
255
|
+
browser: 'chromium'
|
|
256
|
+
});
|
|
220
257
|
id = res.id;
|
|
221
|
-
s.stop(
|
|
258
|
+
s.stop(
|
|
259
|
+
res.reused
|
|
260
|
+
? pc.green(`Reusing existing runner "${name}"`)
|
|
261
|
+
: pc.green(`Registered "${name}" (id ${id})`)
|
|
262
|
+
);
|
|
222
263
|
} catch (e) {
|
|
223
264
|
s.stop(pc.red(`Could not register "${name}": ${e.message}`));
|
|
224
265
|
return;
|
package/backend/server.js
CHANGED
|
@@ -53,7 +53,28 @@ async function start() {
|
|
|
53
53
|
|
|
54
54
|
server.listen(port, async () => {
|
|
55
55
|
console.log(`Backend running on port ${port}${isNodeMode ? ' (node/runner mode)' : ''}`);
|
|
56
|
-
if (isNodeMode)
|
|
56
|
+
if (isNodeMode) {
|
|
57
|
+
// Self-register PID so manage-runners can track and stop this process.
|
|
58
|
+
const runnerId = process.env.RUNNER_ID;
|
|
59
|
+
if (runnerId) {
|
|
60
|
+
const { loadRegistry, saveRegistry } = require('./lib/runnerProcess');
|
|
61
|
+
const registry = loadRegistry();
|
|
62
|
+
registry[runnerId] = { pid: process.pid, port: String(port), startedAt: Date.now() };
|
|
63
|
+
saveRegistry(registry);
|
|
64
|
+
|
|
65
|
+
const cleanup = () => {
|
|
66
|
+
try {
|
|
67
|
+
const reg = loadRegistry();
|
|
68
|
+
delete reg[runnerId];
|
|
69
|
+
saveRegistry(reg);
|
|
70
|
+
} catch {}
|
|
71
|
+
};
|
|
72
|
+
process.once('SIGTERM', cleanup);
|
|
73
|
+
process.once('SIGINT', cleanup);
|
|
74
|
+
process.once('exit', cleanup);
|
|
75
|
+
}
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
57
78
|
|
|
58
79
|
// chokidar v5+ is ESM-only — use dynamic import to stay compatible with CJS
|
|
59
80
|
let chokidar;
|
|
@@ -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({
|
|
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 {
|
|
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({
|
|
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 ({
|
|
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
|
-
{
|
|
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
|
});
|