plum-e2e 1.2.4 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +201 -0
- package/README.md +237 -90
- package/backend/_scaffold/utils/browser.ts +5 -2
- package/backend/app.js +9 -1
- package/backend/config/scripts/generate-report.js +34 -73
- package/backend/config/scripts/run-tests.js +7 -3
- package/backend/constants/triggers.js +67 -0
- package/backend/lib/reportFilename.js +37 -0
- package/backend/lib/testChunker.js +73 -0
- package/backend/middleware/auth.js +32 -0
- package/backend/package.json +4 -2
- package/backend/prisma/migrations/20260616000000_add_runners_and_browser/migration.sql +26 -0
- package/backend/prisma/migrations/20260616000001_cron_runner_ids/migration.sql +6 -0
- package/backend/prisma/migrations/20260617000000_cron_enabled/migration.sql +1 -0
- package/backend/prisma/migrations/20260617000001_report_content/migration.sql +8 -0
- package/backend/prisma/schema.prisma +21 -1
- package/backend/routes/cron.routes.js +28 -0
- package/backend/routes/node.routes.js +121 -0
- package/backend/routes/reports.routes.js +23 -20
- package/backend/routes/runners.routes.js +83 -0
- package/backend/scripts/add-local-runner.js +120 -0
- package/backend/scripts/create-test.js +148 -0
- package/backend/server.js +16 -7
- package/backend/services/backupService.js +3 -30
- package/backend/services/cronService.js +220 -36
- package/backend/services/reportService.js +227 -55
- package/backend/services/runnerService.js +179 -0
- package/backend/websockets/socketHandler.js +162 -21
- package/bin/plum.js +132 -19
- package/docker-compose.node.yml +59 -0
- package/docker-compose.yml +2 -0
- package/frontend/package.json +1 -4
- package/frontend/src/app.css +20 -254
- package/frontend/src/app.html +1 -1
- package/frontend/src/lib/api/reports.js +17 -36
- package/frontend/src/lib/api/runners.js +61 -0
- package/frontend/src/lib/api/schedules.js +34 -5
- package/frontend/src/lib/api/settings.js +5 -5
- package/frontend/src/lib/api/tests.js +2 -19
- package/frontend/src/lib/components/icons/BrowserIcon.svelte +75 -0
- package/frontend/src/lib/components/layout/Nav.svelte +42 -47
- package/frontend/src/lib/components/layout/RunnerPanel.svelte +913 -253
- package/frontend/src/lib/components/ui/Badge.svelte +6 -1
- package/frontend/src/lib/components/ui/ConfirmModal.svelte +98 -0
- package/frontend/{tailwind.config.js → src/lib/components/ui/EmptyState.svelte} +27 -8
- package/frontend/{postcss.config.js → src/lib/components/ui/Toast.svelte} +20 -7
- package/frontend/src/lib/constants.js +36 -0
- package/frontend/src/lib/stores/runner.js +23 -12
- package/frontend/src/lib/styles/global.css +176 -0
- package/frontend/src/lib/styles/reset.css +86 -0
- package/frontend/src/lib/styles/tokens.css +90 -0
- package/frontend/src/lib/utils/format.js +46 -0
- package/frontend/src/routes/+page.svelte +16 -35
- package/frontend/src/routes/reports/+page.svelte +84 -167
- package/frontend/src/routes/reports/{[slug] → [id]}/+page.svelte +304 -76
- package/frontend/src/routes/reports/live/+page.svelte +704 -0
- package/frontend/src/routes/scheduled-tests/+page.svelte +328 -88
- package/frontend/src/routes/settings/+page.svelte +774 -127
- package/frontend/static/favicon-32x32.png +0 -0
- package/frontend/static/favicon.ico +0 -0
- package/package.json +1 -1
- package/frontend/static/favicon.png +0 -0
|
@@ -0,0 +1,121 @@
|
|
|
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 { spawn } = require('child_process');
|
|
21
|
+
const crypto = require('crypto');
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const os = require('os');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
const { authGuard } = require('../middleware/auth');
|
|
26
|
+
const { TRIGGER_REMOTE } = require('../constants/triggers');
|
|
27
|
+
|
|
28
|
+
// In-memory job store for active remote executions.
|
|
29
|
+
// Jobs are purged after 10 minutes post-completion.
|
|
30
|
+
const jobs = {};
|
|
31
|
+
|
|
32
|
+
// Health check — primary server uses this to confirm the node is reachable
|
|
33
|
+
router.get('/ping', authGuard, (req, res) => {
|
|
34
|
+
res.json({ ok: true, mode: process.env.PLUM_MODE || 'server' });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Start a remote test job
|
|
38
|
+
router.post('/execute', authGuard, (req, res) => {
|
|
39
|
+
const { tags, browser = 'chromium', workers = 1, tests = null } = req.body;
|
|
40
|
+
const jobId = crypto.randomUUID();
|
|
41
|
+
|
|
42
|
+
// Write test files sent by the primary into a per-job temp dir
|
|
43
|
+
let tempTestsDir = null;
|
|
44
|
+
if (tests && Object.keys(tests).length > 0) {
|
|
45
|
+
tempTestsDir = path.join(os.tmpdir(), `plum-job-${jobId}`);
|
|
46
|
+
for (const [rel, content] of Object.entries(tests)) {
|
|
47
|
+
const dest = path.join(tempTestsDir, rel);
|
|
48
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
49
|
+
fs.writeFileSync(dest, content, 'utf8');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
jobs[jobId] = {
|
|
54
|
+
status: 'running',
|
|
55
|
+
logs: '',
|
|
56
|
+
exitCode: null,
|
|
57
|
+
startedAt: Date.now(),
|
|
58
|
+
meta: { tags: tags || '', browser, workers },
|
|
59
|
+
tempTestsDir
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const env = {
|
|
63
|
+
...process.env,
|
|
64
|
+
TAG: tags || '',
|
|
65
|
+
TRIGGER: TRIGGER_REMOTE,
|
|
66
|
+
BROWSER: browser,
|
|
67
|
+
REPORT_RUNNERS: String(workers),
|
|
68
|
+
...(tempTestsDir ? { TESTS_ROOT: tempTestsDir } : {})
|
|
69
|
+
};
|
|
70
|
+
if (workers > 1) env.PARALLEL = String(workers);
|
|
71
|
+
|
|
72
|
+
const proc = spawn('npm', ['run', 'test'], { env, shell: true });
|
|
73
|
+
proc.stdout.on('data', (d) => {
|
|
74
|
+
jobs[jobId].logs += d.toString();
|
|
75
|
+
});
|
|
76
|
+
proc.stderr.on('data', (d) => {
|
|
77
|
+
jobs[jobId].logs += d.toString();
|
|
78
|
+
});
|
|
79
|
+
proc.on('close', (code) => {
|
|
80
|
+
jobs[jobId].status = code === 0 ? 'done' : 'error';
|
|
81
|
+
jobs[jobId].exitCode = code;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const reportPath = path.resolve(process.cwd(), 'reports', 'cucumber_report.json');
|
|
85
|
+
if (fs.existsSync(reportPath)) {
|
|
86
|
+
jobs[jobId].reportContent = fs.readFileSync(reportPath, 'utf8');
|
|
87
|
+
}
|
|
88
|
+
} catch {}
|
|
89
|
+
|
|
90
|
+
if (jobs[jobId].tempTestsDir) {
|
|
91
|
+
fs.rm(jobs[jobId].tempTestsDir, { recursive: true, force: true }, () => {});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
setTimeout(() => delete jobs[jobId], 600_000);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
res.json({ jobId, status: 'started' });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Fetch the final report content after a job completes
|
|
101
|
+
router.get('/report/:jobId', authGuard, (req, res) => {
|
|
102
|
+
const job = jobs[req.params.jobId];
|
|
103
|
+
if (!job) return res.status(404).json({ error: 'Job not found' });
|
|
104
|
+
if (!job.reportContent) return res.status(404).json({ error: 'Report not ready' });
|
|
105
|
+
res.json({ content: job.reportContent, meta: job.meta });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Poll job status and streamed logs
|
|
109
|
+
router.get('/execute/:jobId', authGuard, (req, res) => {
|
|
110
|
+
const job = jobs[req.params.jobId];
|
|
111
|
+
if (!job) return res.status(404).json({ error: 'Job not found' });
|
|
112
|
+
|
|
113
|
+
const offset = parseInt(req.query.offset || '0', 10);
|
|
114
|
+
res.json({
|
|
115
|
+
status: job.status,
|
|
116
|
+
logs: job.logs.slice(offset),
|
|
117
|
+
exitCode: job.exitCode
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
module.exports = router;
|
|
@@ -23,47 +23,50 @@ router.get('/', async (req, res) => {
|
|
|
23
23
|
try {
|
|
24
24
|
const reports = await reportService.getAllReports();
|
|
25
25
|
res.json({ reports });
|
|
26
|
-
} catch
|
|
27
|
-
console.error('Error fetching reports:', error);
|
|
26
|
+
} catch {
|
|
28
27
|
res.status(500).json({ error: 'Failed to fetch reports' });
|
|
29
28
|
}
|
|
30
29
|
});
|
|
31
30
|
|
|
32
31
|
router.get('/latest', async (req, res) => {
|
|
33
32
|
try {
|
|
34
|
-
const
|
|
35
|
-
res.json({
|
|
36
|
-
} catch
|
|
37
|
-
console.error('Error fetching latest report:', error);
|
|
33
|
+
const latestReportId = await reportService.getLatestReportId();
|
|
34
|
+
res.json({ latestReportId });
|
|
35
|
+
} catch {
|
|
38
36
|
res.status(500).json({ error: 'Failed to fetch latest report' });
|
|
39
37
|
}
|
|
40
38
|
});
|
|
41
39
|
|
|
42
|
-
router.get('/:
|
|
43
|
-
const
|
|
40
|
+
router.get('/:id', async (req, res) => {
|
|
41
|
+
const id = parseInt(req.params.id, 10);
|
|
42
|
+
if (isNaN(id)) return res.status(400).json({ error: 'Invalid report id' });
|
|
43
|
+
const detail = await reportService.getReportDetail(id);
|
|
44
44
|
if (!detail) return res.status(404).json({ error: 'Report not found' });
|
|
45
45
|
res.json(detail);
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
router.delete('/bulk', async (req, res) => {
|
|
49
|
-
const {
|
|
50
|
-
if (!Array.isArray(
|
|
51
|
-
return res.status(400).json({ error: '
|
|
49
|
+
const { ids } = req.body;
|
|
50
|
+
if (!Array.isArray(ids) || ids.length === 0) {
|
|
51
|
+
return res.status(400).json({ error: 'ids array required' });
|
|
52
|
+
}
|
|
53
|
+
const numericIds = ids.map(Number).filter((n) => !isNaN(n));
|
|
54
|
+
if (numericIds.length === 0) return res.status(400).json({ error: 'No valid ids' });
|
|
52
55
|
try {
|
|
53
|
-
await reportService.deleteReports(
|
|
54
|
-
res.json({ deleted:
|
|
55
|
-
} catch
|
|
56
|
-
console.error('Error deleting reports:', error);
|
|
56
|
+
await reportService.deleteReports(numericIds);
|
|
57
|
+
res.json({ deleted: numericIds.length });
|
|
58
|
+
} catch {
|
|
57
59
|
res.status(500).json({ error: 'Failed to delete reports' });
|
|
58
60
|
}
|
|
59
61
|
});
|
|
60
62
|
|
|
61
|
-
router.delete('/:
|
|
63
|
+
router.delete('/:id', async (req, res) => {
|
|
64
|
+
const id = parseInt(req.params.id, 10);
|
|
65
|
+
if (isNaN(id)) return res.status(400).json({ error: 'Invalid report id' });
|
|
62
66
|
try {
|
|
63
|
-
await reportService.deleteReport(
|
|
64
|
-
res.json({ deleted:
|
|
65
|
-
} catch
|
|
66
|
-
console.error('Error deleting report:', error);
|
|
67
|
+
await reportService.deleteReport(id);
|
|
68
|
+
res.json({ deleted: id });
|
|
69
|
+
} catch {
|
|
67
70
|
res.status(500).json({ error: 'Failed to delete report' });
|
|
68
71
|
}
|
|
69
72
|
});
|
|
@@ -0,0 +1,83 @@
|
|
|
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 runnerService = require('../services/runnerService');
|
|
21
|
+
|
|
22
|
+
router.get('/', async (req, res) => {
|
|
23
|
+
try {
|
|
24
|
+
const runners = await runnerService.getAll();
|
|
25
|
+
res.json({ runners });
|
|
26
|
+
} catch (e) {
|
|
27
|
+
res.status(500).json({ error: 'Failed to fetch runners' });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
router.post('/probe', async (req, res) => {
|
|
32
|
+
try {
|
|
33
|
+
const { url, token } = req.body;
|
|
34
|
+
if (!url || !token)
|
|
35
|
+
return res.status(400).json({ ok: false, error: 'url and token are required' });
|
|
36
|
+
const result = await runnerService.probe({ url, token });
|
|
37
|
+
res.json(result);
|
|
38
|
+
} catch (e) {
|
|
39
|
+
res.status(500).json({ ok: false, error: e.message });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
router.post('/', async (req, res) => {
|
|
44
|
+
try {
|
|
45
|
+
const { name, url, token, browser } = req.body;
|
|
46
|
+
if (!name || !url || !token)
|
|
47
|
+
return res.status(400).json({ error: 'name, url and token are required' });
|
|
48
|
+
const runner = await runnerService.create({ name, url, token, browser });
|
|
49
|
+
res.status(201).json({ runner });
|
|
50
|
+
} catch (e) {
|
|
51
|
+
res.status(500).json({ error: 'Failed to create runner' });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
router.put('/:id', async (req, res) => {
|
|
56
|
+
try {
|
|
57
|
+
const { name, url, token, browser } = req.body;
|
|
58
|
+
const runner = await runnerService.update(req.params.id, { name, url, token, browser });
|
|
59
|
+
res.json({ runner });
|
|
60
|
+
} catch (e) {
|
|
61
|
+
res.status(500).json({ error: 'Failed to update runner' });
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
router.delete('/:id', async (req, res) => {
|
|
66
|
+
try {
|
|
67
|
+
await runnerService.remove(req.params.id);
|
|
68
|
+
res.json({ message: 'Runner deleted' });
|
|
69
|
+
} catch (e) {
|
|
70
|
+
res.status(500).json({ error: 'Failed to delete runner' });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
router.post('/:id/ping', async (req, res) => {
|
|
75
|
+
try {
|
|
76
|
+
const result = await runnerService.ping(req.params.id);
|
|
77
|
+
res.json(result);
|
|
78
|
+
} catch (e) {
|
|
79
|
+
res.status(500).json({ ok: false, error: e.message });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
module.exports = router;
|
|
@@ -0,0 +1,120 @@
|
|
|
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
|
+
/**
|
|
19
|
+
* Interactively registers a local node runner with the Plum primary server,
|
|
20
|
+
* then starts the node server in the foreground.
|
|
21
|
+
*
|
|
22
|
+
* Usage: node scripts/add-local-runner.js
|
|
23
|
+
* or: npm run add-local-runner (from the backend directory)
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const readline = require('readline');
|
|
27
|
+
const { spawn } = require('child_process');
|
|
28
|
+
const path = require('path');
|
|
29
|
+
|
|
30
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
31
|
+
|
|
32
|
+
function ask(question, defaultValue) {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const hint = defaultValue ? ` (default: ${defaultValue})` : '';
|
|
35
|
+
rl.question(` ${question}${hint}: `, (answer) => {
|
|
36
|
+
resolve(answer.trim() || defaultValue || '');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function main() {
|
|
42
|
+
console.log('\nRegister a local node runner with the Plum server');
|
|
43
|
+
console.log('─'.repeat(51));
|
|
44
|
+
console.log('The runner URL will use host.docker.internal so that');
|
|
45
|
+
console.log('the Plum Docker container can reach your host machine.\n');
|
|
46
|
+
|
|
47
|
+
const name = await ask('Runner name', 'local-node');
|
|
48
|
+
const token = await ask('Node auth token (NODE_TOKEN)', process.env.NODE_TOKEN || 'test123');
|
|
49
|
+
const port = await ask('Port the node will run on', '3002');
|
|
50
|
+
const browser = await ask('Default browser (chromium/firefox/webkit)', 'chromium');
|
|
51
|
+
const apiUrl = await ask('Plum server API URL', 'http://localhost:3001');
|
|
52
|
+
|
|
53
|
+
rl.close();
|
|
54
|
+
|
|
55
|
+
const nodeUrl = `http://host.docker.internal:${port}`;
|
|
56
|
+
console.log(`\nRegistering "${name}" at ${nodeUrl} via ${apiUrl}...`);
|
|
57
|
+
|
|
58
|
+
let res;
|
|
59
|
+
try {
|
|
60
|
+
res = await fetch(`${apiUrl}/runners`, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { 'Content-Type': 'application/json' },
|
|
63
|
+
body: JSON.stringify({ name, url: nodeUrl, token, browser })
|
|
64
|
+
});
|
|
65
|
+
} catch (e) {
|
|
66
|
+
console.error(`\n✗ Could not reach Plum server at ${apiUrl}`);
|
|
67
|
+
console.error(` ${e.message}`);
|
|
68
|
+
console.error('\nMake sure the Docker stack is running:');
|
|
69
|
+
console.error(' docker compose up -d');
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const body = await res.json().catch(() => ({}));
|
|
74
|
+
|
|
75
|
+
if (!res.ok) {
|
|
76
|
+
console.error(`\n✗ Server returned HTTP ${res.status}`);
|
|
77
|
+
if (body.error) console.error(` ${body.error}`);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (body.error) {
|
|
82
|
+
console.error('\n✗ Error:', body.error);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const runner = body.runner;
|
|
87
|
+
console.log(`\n✓ Runner "${runner.name}" registered (id: ${runner.id})`);
|
|
88
|
+
console.log(` URL: ${runner.url}`);
|
|
89
|
+
console.log(` Token: ${runner.token}`);
|
|
90
|
+
console.log(` Browser: ${runner.browser}`);
|
|
91
|
+
console.log('\nStarting node server… (Ctrl+C to stop)\n');
|
|
92
|
+
|
|
93
|
+
// Start the node server in the foreground — the script stays alive until the user kills it
|
|
94
|
+
const serverPath = path.resolve(__dirname, '..', 'server.js');
|
|
95
|
+
const child = spawn(process.execPath, [serverPath], {
|
|
96
|
+
env: {
|
|
97
|
+
...process.env,
|
|
98
|
+
NODE_TOKEN: token,
|
|
99
|
+
PLUM_MODE: 'node',
|
|
100
|
+
PORT: port
|
|
101
|
+
},
|
|
102
|
+
stdio: 'inherit',
|
|
103
|
+
cwd: path.resolve(__dirname, '..')
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
child.on('error', (e) => {
|
|
107
|
+
console.error('✗ Failed to start node server:', e.message);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
child.on('close', (code) => {
|
|
112
|
+
process.exit(code ?? 0);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
main().catch((e) => {
|
|
117
|
+
rl.close();
|
|
118
|
+
console.error('\n✗', e.message);
|
|
119
|
+
process.exit(1);
|
|
120
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
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
|
+
/**
|
|
19
|
+
* Scaffolds a new feature file, page object, and step definition from templates.
|
|
20
|
+
*
|
|
21
|
+
* Usage: node scripts/create-test.js
|
|
22
|
+
* or: npm run create-test (from the backend directory)
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const readline = require('readline');
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
|
|
29
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
30
|
+
|
|
31
|
+
function ask(question, defaultValue) {
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
const hint = defaultValue ? ` (default: ${defaultValue})` : '';
|
|
34
|
+
rl.question(` ${question}${hint}: `, (answer) => {
|
|
35
|
+
resolve(answer.trim() || defaultValue || '');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function toPascalCase(str) {
|
|
41
|
+
return str
|
|
42
|
+
.replace(/[-_\s]+(.)/g, (_, c) => c.toUpperCase())
|
|
43
|
+
.replace(/^(.)/, (c) => c.toUpperCase());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function toKebabCase(str) {
|
|
47
|
+
return str
|
|
48
|
+
.replace(/([A-Z])/g, (c) => `-${c.toLowerCase()}`)
|
|
49
|
+
.replace(/[-_\s]+/g, '-')
|
|
50
|
+
.replace(/^-/, '');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function main() {
|
|
54
|
+
const testsRoot = process.env.TESTS_ROOT || path.resolve(__dirname, '..', 'tests');
|
|
55
|
+
|
|
56
|
+
console.log('\nScaffold a new test');
|
|
57
|
+
console.log('─'.repeat(40));
|
|
58
|
+
|
|
59
|
+
const rawName = await ask('Feature name (e.g. "Cart", "Checkout")', '');
|
|
60
|
+
if (!rawName) {
|
|
61
|
+
console.error('\n✗ Feature name is required.\n');
|
|
62
|
+
rl.close();
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const pascal = toPascalCase(rawName);
|
|
67
|
+
const kebab = toKebabCase(pascal);
|
|
68
|
+
const suiteTag = `@suite-${kebab}`;
|
|
69
|
+
const testTag = `@test-${kebab}-1`;
|
|
70
|
+
|
|
71
|
+
const featurePath = path.join(testsRoot, 'features', `${pascal}.feature`);
|
|
72
|
+
const pagePath = path.join(testsRoot, 'pages', `${pascal}Page.ts`);
|
|
73
|
+
const stepsPath = path.join(testsRoot, 'step_definitions', `${pascal}Steps.ts`);
|
|
74
|
+
|
|
75
|
+
const conflicts = [featurePath, pagePath, stepsPath].filter(fs.existsSync);
|
|
76
|
+
if (conflicts.length > 0) {
|
|
77
|
+
console.error('\n✗ The following files already exist:');
|
|
78
|
+
conflicts.forEach((f) => console.error(` ${f}`));
|
|
79
|
+
console.error('\nDelete them first or choose a different name.\n');
|
|
80
|
+
rl.close();
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
rl.close();
|
|
85
|
+
|
|
86
|
+
const feature = `${suiteTag}
|
|
87
|
+
Feature: ${pascal}
|
|
88
|
+
|
|
89
|
+
${testTag}
|
|
90
|
+
Scenario: Example scenario
|
|
91
|
+
Given I am on the ${pascal} page
|
|
92
|
+
When I perform an action
|
|
93
|
+
Then I should see the expected result
|
|
94
|
+
`;
|
|
95
|
+
|
|
96
|
+
const page = `import { page } from '../utils/browser';
|
|
97
|
+
|
|
98
|
+
export class ${pascal}Page {
|
|
99
|
+
static async goTo() {
|
|
100
|
+
await page().goto(process.env.BASE_URL as string);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
static async performAction() {
|
|
104
|
+
// TODO: implement
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
static async verifyResult() {
|
|
108
|
+
// TODO: implement
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
`;
|
|
112
|
+
|
|
113
|
+
const steps = `import { Given, When, Then } from '@cucumber/cucumber';
|
|
114
|
+
import { ${pascal}Page } from '../pages/${pascal}Page';
|
|
115
|
+
|
|
116
|
+
Given('I am on the ${pascal} page', async () => {
|
|
117
|
+
await ${pascal}Page.goTo();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
When('I perform an action', async () => {
|
|
121
|
+
await ${pascal}Page.performAction();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
Then('I should see the expected result', async () => {
|
|
125
|
+
await ${pascal}Page.verifyResult();
|
|
126
|
+
});
|
|
127
|
+
`;
|
|
128
|
+
|
|
129
|
+
fs.mkdirSync(path.join(testsRoot, 'features'), { recursive: true });
|
|
130
|
+
fs.mkdirSync(path.join(testsRoot, 'pages'), { recursive: true });
|
|
131
|
+
fs.mkdirSync(path.join(testsRoot, 'step_definitions'), { recursive: true });
|
|
132
|
+
|
|
133
|
+
fs.writeFileSync(featurePath, feature, 'utf8');
|
|
134
|
+
fs.writeFileSync(pagePath, page, 'utf8');
|
|
135
|
+
fs.writeFileSync(stepsPath, steps, 'utf8');
|
|
136
|
+
|
|
137
|
+
console.log('\n✅ Test scaffold created:');
|
|
138
|
+
console.log(` ${featurePath}`);
|
|
139
|
+
console.log(` ${pagePath}`);
|
|
140
|
+
console.log(` ${stepsPath}`);
|
|
141
|
+
console.log(`\nRun with: npm test -- ${testTag}\n`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
main().catch((e) => {
|
|
145
|
+
rl.close();
|
|
146
|
+
console.error('\n✗', e.message);
|
|
147
|
+
process.exit(1);
|
|
148
|
+
});
|
package/backend/server.js
CHANGED
|
@@ -29,19 +29,28 @@ const fs = require('fs');
|
|
|
29
29
|
const testsDir = path.resolve(process.cwd(), 'tests');
|
|
30
30
|
|
|
31
31
|
if (!fs.existsSync(testsDir)) {
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
if (process.env.PLUM_MODE === 'node') {
|
|
33
|
+
console.warn('⚠️ No tests folder found — will be populated when a job is received');
|
|
34
|
+
} else {
|
|
35
|
+
console.error('❌ No tests folder found at /app/tests');
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
} else {
|
|
39
|
+
console.log('📂 Loading tests from:', testsDir);
|
|
34
40
|
}
|
|
35
41
|
|
|
36
|
-
|
|
42
|
+
const isNodeMode = process.env.PLUM_MODE === 'node';
|
|
43
|
+
const port = parseInt(process.env.PORT || '3001', 10);
|
|
37
44
|
|
|
38
45
|
socketHandler(io);
|
|
39
|
-
cronService.setSocketIO(io);
|
|
46
|
+
if (!isNodeMode) cronService.setSocketIO(io);
|
|
40
47
|
|
|
41
48
|
async function start() {
|
|
42
|
-
await cronService.init();
|
|
43
|
-
|
|
44
|
-
|
|
49
|
+
if (!isNodeMode) await cronService.init();
|
|
50
|
+
|
|
51
|
+
server.listen(port, async () => {
|
|
52
|
+
console.log(`Backend running on port ${port}${isNodeMode ? ' (node/runner mode)' : ''}`);
|
|
53
|
+
if (isNodeMode) return; // nodes don't watch files or schedule jobs
|
|
45
54
|
|
|
46
55
|
// chokidar v5+ is ESM-only — use dynamic import to stay compatible with CJS
|
|
47
56
|
let chokidar;
|
|
@@ -18,36 +18,23 @@
|
|
|
18
18
|
const prisma = require('./prisma');
|
|
19
19
|
|
|
20
20
|
const exportAll = async () => {
|
|
21
|
-
const [cronJobs,
|
|
21
|
+
const [cronJobs, project] = await Promise.all([
|
|
22
22
|
prisma.cronJob.findMany({ orderBy: { createdAt: 'asc' } }),
|
|
23
|
-
prisma.report.findMany({
|
|
24
|
-
orderBy: { createdAt: 'asc' },
|
|
25
|
-
include: { cronJob: { select: { taskName: true } } }
|
|
26
|
-
}),
|
|
27
23
|
prisma.project.findUnique({ where: { id: 1 } })
|
|
28
24
|
]);
|
|
29
25
|
|
|
30
|
-
// Annotate each report with the cronJob's taskName for portable restoration
|
|
31
|
-
const portableReports = reports.map(({ cronJob, cronJobId, ...r }) => ({
|
|
32
|
-
...r,
|
|
33
|
-
cronJobTaskName: cronJob?.taskName ?? null
|
|
34
|
-
}));
|
|
35
|
-
|
|
36
26
|
return {
|
|
37
27
|
version: '1',
|
|
38
28
|
exportedAt: new Date().toISOString(),
|
|
39
29
|
cronJobs: cronJobs.map(({ id, createdAt, updatedAt, reports: _, ...r }) => r),
|
|
40
|
-
reports: portableReports.map(({ id, createdAt, ...r }) => r),
|
|
41
30
|
project: project ? { name: project.name, logoUrl: project.logoUrl } : null
|
|
42
31
|
};
|
|
43
32
|
};
|
|
44
33
|
|
|
45
|
-
const importAll = async ({ cronJobs = [],
|
|
34
|
+
const importAll = async ({ cronJobs = [], project = null }, cronService) => {
|
|
46
35
|
await prisma.$transaction(async (tx) => {
|
|
47
|
-
// Upsert cron jobs and build taskName → id map
|
|
48
|
-
const taskNameToId = {};
|
|
49
36
|
for (const job of cronJobs) {
|
|
50
|
-
|
|
37
|
+
await tx.cronJob.upsert({
|
|
51
38
|
where: { taskName: job.taskName },
|
|
52
39
|
create: {
|
|
53
40
|
taskName: job.taskName,
|
|
@@ -57,21 +44,8 @@ const importAll = async ({ cronJobs = [], reports = [], project = null }, cronSe
|
|
|
57
44
|
},
|
|
58
45
|
update: { cronExpression: job.cronExpression, tags: job.tags, workers: job.workers ?? 1 }
|
|
59
46
|
});
|
|
60
|
-
taskNameToId[job.taskName] = upserted.id;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Upsert reports, resolving cronJobId from the taskName map
|
|
64
|
-
for (const report of reports) {
|
|
65
|
-
const { cronJobTaskName, ...data } = report;
|
|
66
|
-
const cronJobId = cronJobTaskName ? (taskNameToId[cronJobTaskName] ?? null) : null;
|
|
67
|
-
await tx.report.upsert({
|
|
68
|
-
where: { fileName: data.fileName },
|
|
69
|
-
create: { ...data, cronJobId },
|
|
70
|
-
update: { ...data, cronJobId }
|
|
71
|
-
});
|
|
72
47
|
}
|
|
73
48
|
|
|
74
|
-
// Restore project settings
|
|
75
49
|
if (project) {
|
|
76
50
|
await tx.project.upsert({
|
|
77
51
|
where: { id: 1 },
|
|
@@ -81,7 +55,6 @@ const importAll = async ({ cronJobs = [], reports = [], project = null }, cronSe
|
|
|
81
55
|
}
|
|
82
56
|
});
|
|
83
57
|
|
|
84
|
-
// Re-schedule cron jobs after import
|
|
85
58
|
if (cronService) await cronService.reload();
|
|
86
59
|
};
|
|
87
60
|
|