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.
Files changed (62) hide show
  1. package/CLAUDE.md +201 -0
  2. package/README.md +237 -90
  3. package/backend/_scaffold/utils/browser.ts +5 -2
  4. package/backend/app.js +9 -1
  5. package/backend/config/scripts/generate-report.js +34 -73
  6. package/backend/config/scripts/run-tests.js +7 -3
  7. package/backend/constants/triggers.js +67 -0
  8. package/backend/lib/reportFilename.js +37 -0
  9. package/backend/lib/testChunker.js +73 -0
  10. package/backend/middleware/auth.js +32 -0
  11. package/backend/package.json +4 -2
  12. package/backend/prisma/migrations/20260616000000_add_runners_and_browser/migration.sql +26 -0
  13. package/backend/prisma/migrations/20260616000001_cron_runner_ids/migration.sql +6 -0
  14. package/backend/prisma/migrations/20260617000000_cron_enabled/migration.sql +1 -0
  15. package/backend/prisma/migrations/20260617000001_report_content/migration.sql +8 -0
  16. package/backend/prisma/schema.prisma +21 -1
  17. package/backend/routes/cron.routes.js +28 -0
  18. package/backend/routes/node.routes.js +121 -0
  19. package/backend/routes/reports.routes.js +23 -20
  20. package/backend/routes/runners.routes.js +83 -0
  21. package/backend/scripts/add-local-runner.js +120 -0
  22. package/backend/scripts/create-test.js +148 -0
  23. package/backend/server.js +16 -7
  24. package/backend/services/backupService.js +3 -30
  25. package/backend/services/cronService.js +220 -36
  26. package/backend/services/reportService.js +227 -55
  27. package/backend/services/runnerService.js +179 -0
  28. package/backend/websockets/socketHandler.js +162 -21
  29. package/bin/plum.js +132 -19
  30. package/docker-compose.node.yml +59 -0
  31. package/docker-compose.yml +2 -0
  32. package/frontend/package.json +1 -4
  33. package/frontend/src/app.css +20 -254
  34. package/frontend/src/app.html +1 -1
  35. package/frontend/src/lib/api/reports.js +17 -36
  36. package/frontend/src/lib/api/runners.js +61 -0
  37. package/frontend/src/lib/api/schedules.js +34 -5
  38. package/frontend/src/lib/api/settings.js +5 -5
  39. package/frontend/src/lib/api/tests.js +2 -19
  40. package/frontend/src/lib/components/icons/BrowserIcon.svelte +75 -0
  41. package/frontend/src/lib/components/layout/Nav.svelte +42 -47
  42. package/frontend/src/lib/components/layout/RunnerPanel.svelte +913 -253
  43. package/frontend/src/lib/components/ui/Badge.svelte +6 -1
  44. package/frontend/src/lib/components/ui/ConfirmModal.svelte +98 -0
  45. package/frontend/{tailwind.config.js → src/lib/components/ui/EmptyState.svelte} +27 -8
  46. package/frontend/{postcss.config.js → src/lib/components/ui/Toast.svelte} +20 -7
  47. package/frontend/src/lib/constants.js +36 -0
  48. package/frontend/src/lib/stores/runner.js +23 -12
  49. package/frontend/src/lib/styles/global.css +176 -0
  50. package/frontend/src/lib/styles/reset.css +86 -0
  51. package/frontend/src/lib/styles/tokens.css +90 -0
  52. package/frontend/src/lib/utils/format.js +46 -0
  53. package/frontend/src/routes/+page.svelte +16 -35
  54. package/frontend/src/routes/reports/+page.svelte +84 -167
  55. package/frontend/src/routes/reports/{[slug] → [id]}/+page.svelte +304 -76
  56. package/frontend/src/routes/reports/live/+page.svelte +704 -0
  57. package/frontend/src/routes/scheduled-tests/+page.svelte +328 -88
  58. package/frontend/src/routes/settings/+page.svelte +774 -127
  59. package/frontend/static/favicon-32x32.png +0 -0
  60. package/frontend/static/favicon.ico +0 -0
  61. package/package.json +1 -1
  62. 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 (error) {
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 latestReport = await reportService.getLatestReport();
35
- res.json({ latestReport });
36
- } catch (error) {
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('/:fileName/detail', (req, res) => {
43
- const detail = reportService.getReportDetail(req.params.fileName);
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 { fileNames } = req.body;
50
- if (!Array.isArray(fileNames) || fileNames.length === 0)
51
- return res.status(400).json({ error: 'fileNames array required' });
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(fileNames);
54
- res.json({ deleted: fileNames.length });
55
- } catch (error) {
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('/:fileName', async (req, res) => {
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(req.params.fileName);
64
- res.json({ deleted: req.params.fileName });
65
- } catch (error) {
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
- console.error('❌ No tests folder found at /app/tests');
33
- process.exit(1);
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
- console.log('📂 Loading tests from:', testsDir);
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
- server.listen(3001, async () => {
44
- console.log('Backend running on port 3001');
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, reports, project] = await Promise.all([
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 = [], reports = [], project = null }, cronService) => {
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
- const upserted = await tx.cronJob.upsert({
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