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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chromium, Browser, BrowserContext, Page } from 'playwright';
|
|
1
|
+
import { chromium, firefox, webkit, Browser, BrowserContext, Page } from 'playwright';
|
|
2
2
|
|
|
3
3
|
let _browser: Browser;
|
|
4
4
|
let _context: BrowserContext;
|
|
@@ -8,7 +8,10 @@ export const page = (): Page => _page;
|
|
|
8
8
|
|
|
9
9
|
export async function setup(): Promise<void> {
|
|
10
10
|
const isHeadless = process.env.IS_HEADLESS?.toLowerCase() !== 'false';
|
|
11
|
-
|
|
11
|
+
const browserName = (process.env.BROWSER || 'chromium').toLowerCase();
|
|
12
|
+
const browserType =
|
|
13
|
+
browserName === 'firefox' ? firefox : browserName === 'webkit' ? webkit : chromium;
|
|
14
|
+
_browser = await browserType.launch({ headless: isHeadless });
|
|
12
15
|
_context = await _browser.newContext();
|
|
13
16
|
_page = await _context.newPage();
|
|
14
17
|
}
|
package/backend/app.js
CHANGED
|
@@ -15,25 +15,33 @@
|
|
|
15
15
|
* along with Plum. If not, see https://www.gnu.org/licenses/.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
const path = require('path');
|
|
18
19
|
const express = require('express');
|
|
19
20
|
const cors = require('cors');
|
|
21
|
+
const { SCREENSHOTS_DIR } = require('./lib/reportFilename');
|
|
20
22
|
const app = express();
|
|
21
23
|
|
|
22
24
|
app.use(cors({ origin: '*' }));
|
|
23
25
|
app.use(express.json());
|
|
24
26
|
|
|
27
|
+
// Serve screenshot files written during report processing
|
|
28
|
+
app.use('/screenshots', express.static(SCREENSHOTS_DIR));
|
|
29
|
+
|
|
25
30
|
// Routes
|
|
26
31
|
const testRoutes = require('./routes/tests.routes');
|
|
27
32
|
const reportRoutes = require('./routes/reports.routes');
|
|
28
33
|
const cronRoutes = require('./routes/cron.routes');
|
|
29
34
|
const settingsRoutes = require('./routes/settings.routes');
|
|
30
35
|
const backupRoutes = require('./routes/backup.routes');
|
|
36
|
+
const runnerRoutes = require('./routes/runners.routes');
|
|
37
|
+
const nodeRoutes = require('./routes/node.routes');
|
|
31
38
|
|
|
32
39
|
app.use('/tests', testRoutes);
|
|
33
40
|
app.use('/reports', reportRoutes);
|
|
34
41
|
app.use('/cron-jobs', cronRoutes);
|
|
35
|
-
app.use('/reports', express.static('reports'));
|
|
36
42
|
app.use('/settings', settingsRoutes);
|
|
37
43
|
app.use('/backup', backupRoutes);
|
|
44
|
+
app.use('/runners', runnerRoutes);
|
|
45
|
+
app.use('/api', nodeRoutes);
|
|
38
46
|
|
|
39
47
|
module.exports = app;
|
|
@@ -17,83 +17,44 @@
|
|
|
17
17
|
|
|
18
18
|
const fs = require('fs');
|
|
19
19
|
const path = require('path');
|
|
20
|
-
const
|
|
21
|
-
const
|
|
20
|
+
const { normaliseTrigger } = require('../../constants/triggers');
|
|
21
|
+
const { REPORTS_DIR } = require('../../lib/reportFilename');
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
if (!fs.existsSync(screenshotsDir)) fs.mkdirSync(screenshotsDir, { recursive: true });
|
|
23
|
+
const jsonReportFile = path.join(REPORTS_DIR, 'cucumber_report.json');
|
|
25
24
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
if (!fs.existsSync(jsonReportFile)) {
|
|
26
|
+
console.log('No cucumber_report.json found — skipping report save.');
|
|
27
|
+
process.exit(0);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Remote runner nodes (PLUM_MODE=node) have no DB access.
|
|
31
|
+
// The primary server reads cucumber_report.json via readCucumberReportFile()
|
|
32
|
+
// and saves the combined report after all lanes finish.
|
|
33
|
+
if (process.env.PLUM_MODE === 'node') process.exit(0);
|
|
29
34
|
|
|
30
|
-
|
|
35
|
+
(async () => {
|
|
31
36
|
try {
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
const raw = JSON.parse(fs.readFileSync(jsonReportFile, 'utf8'));
|
|
38
|
+
const reportService = require('../../services/reportService');
|
|
39
|
+
const triggerType = normaliseTrigger(process.env.TRIGGER);
|
|
40
|
+
const rawTag = process.env.TAG || '@all-tests';
|
|
41
|
+
const nodeCount = Math.max(
|
|
42
|
+
1,
|
|
43
|
+
parseInt(process.env.REPORT_RUNNERS || process.env.PARALLEL || '1', 10) || 1
|
|
37
44
|
);
|
|
38
|
-
|
|
45
|
+
|
|
46
|
+
const saved = await reportService.saveReport({
|
|
47
|
+
rawCucumberJson: raw,
|
|
48
|
+
tags: rawTag,
|
|
49
|
+
triggerType,
|
|
50
|
+
nodeCount,
|
|
51
|
+
browser: process.env.BROWSER || 'chromium',
|
|
52
|
+
runnerName: process.env.RUNNER_NAME || null,
|
|
53
|
+
runnerId: process.env.RUNNER_ID || null
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
console.log(`Report saved to database (id: ${saved.id})`);
|
|
39
57
|
} catch (e) {
|
|
40
|
-
console.error('Could not
|
|
58
|
+
console.error('Could not save report to database:', e.message);
|
|
41
59
|
}
|
|
42
|
-
}
|
|
43
|
-
console.log('No cucumber_report.json found.');
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Build filename with same convention as before, now .json
|
|
47
|
-
let tag = process.env.TAG || '(@all-tests)';
|
|
48
|
-
if (tag && !tag.startsWith('(')) tag = `(${tag})`;
|
|
49
|
-
|
|
50
|
-
const timestamp = new Date().toISOString().replace(/[-:.]/g, '_');
|
|
51
|
-
const runners = Number.parseInt(process.env.REPORT_RUNNERS || process.env.PARALLEL || '1', 10);
|
|
52
|
-
const runnerCount = Number.isFinite(runners) && runners > 0 ? runners : 1;
|
|
53
|
-
const reportFileName = `${status}_cucumber_report_${process.env.TRIGGER}_${tag}_runners_${runnerCount}_${timestamp}.json`;
|
|
54
|
-
|
|
55
|
-
// Save a timestamped snapshot of the cucumber JSON
|
|
56
|
-
if (fs.existsSync(jsonReportFile)) {
|
|
57
|
-
fs.copyFileSync(jsonReportFile, path.join(reportsDir, reportFileName));
|
|
58
|
-
console.log(`Report saved: ${reportFileName}`);
|
|
59
|
-
|
|
60
|
-
// Persist metadata to database
|
|
61
|
-
(async () => {
|
|
62
|
-
try {
|
|
63
|
-
const { PrismaClient } = require('@prisma/client');
|
|
64
|
-
const prisma = new PrismaClient();
|
|
65
|
-
|
|
66
|
-
const triggerType = process.env.TRIGGER || 'command-line-trigger';
|
|
67
|
-
const builtInTriggers = ['manual-trigger', 'command-line-trigger', 'undefined'];
|
|
68
|
-
let cronJobId = null;
|
|
69
|
-
|
|
70
|
-
if (!builtInTriggers.includes(triggerType)) {
|
|
71
|
-
const job = await prisma.cronJob.findUnique({ where: { taskName: triggerType } });
|
|
72
|
-
if (job) cronJobId = job.id;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const rawTag = process.env.TAG || '@all-tests';
|
|
76
|
-
const tags = rawTag.replace(/^\(|\)$/g, '');
|
|
77
|
-
|
|
78
|
-
await prisma.report.upsert({
|
|
79
|
-
where: { fileName: reportFileName },
|
|
80
|
-
create: {
|
|
81
|
-
fileName: reportFileName,
|
|
82
|
-
status,
|
|
83
|
-
tags,
|
|
84
|
-
triggerType,
|
|
85
|
-
runners: runnerCount,
|
|
86
|
-
cronJobId
|
|
87
|
-
},
|
|
88
|
-
update: {}
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
await prisma.$disconnect();
|
|
92
|
-
console.log('Report metadata saved to database.');
|
|
93
|
-
} catch (e) {
|
|
94
|
-
console.error('Could not save report metadata to DB:', e.message);
|
|
95
|
-
}
|
|
96
|
-
})();
|
|
97
|
-
} else {
|
|
98
|
-
console.log('Skipping report save — no cucumber_report.json.');
|
|
99
|
-
}
|
|
60
|
+
})();
|
|
@@ -23,20 +23,24 @@ const parallel =
|
|
|
23
23
|
process.env.PARALLEL || (parallelIdx !== -1 ? process.argv[parallelIdx + 1] : null);
|
|
24
24
|
const runners = parallel || process.env.REPORT_RUNNERS || '1';
|
|
25
25
|
const tag = process.env.TAG || process.argv.slice(2).find((a) => a.startsWith('@'));
|
|
26
|
+
const browser = process.env.BROWSER || 'chromium';
|
|
26
27
|
|
|
27
28
|
try {
|
|
29
|
+
const testsRoot = (process.env.TESTS_ROOT || 'tests').replace(/\\/g, '/');
|
|
30
|
+
|
|
28
31
|
const baseCommand = [
|
|
29
32
|
'npx',
|
|
30
33
|
'cross-env',
|
|
34
|
+
`BROWSER=${browser}`,
|
|
31
35
|
'TS_NODE_TRANSPILE_ONLY=true',
|
|
32
36
|
'cucumber-js',
|
|
33
|
-
|
|
37
|
+
`${testsRoot}/features/**/*.feature`,
|
|
34
38
|
'--require-module',
|
|
35
39
|
'ts-node/register',
|
|
36
40
|
'--require',
|
|
37
|
-
|
|
41
|
+
`${testsRoot}/utils/hooks.ts`,
|
|
38
42
|
'--require',
|
|
39
|
-
|
|
43
|
+
`${testsRoot}/step_definitions/**/*.ts`,
|
|
40
44
|
'--format',
|
|
41
45
|
'json:reports/cucumber_report.json'
|
|
42
46
|
];
|
|
@@ -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
|
+
/**
|
|
19
|
+
* Authoritative trigger type values stored in Report.triggerType.
|
|
20
|
+
*
|
|
21
|
+
* Manual and CLI are the only fixed values.
|
|
22
|
+
* Scheduled runs store the cron job's taskName as the triggerType.
|
|
23
|
+
*/
|
|
24
|
+
const TRIGGER_TYPE = Object.freeze({
|
|
25
|
+
MANUAL: 'manual-trigger',
|
|
26
|
+
CLI: 'command-line-trigger'
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Sentinel written into partial node-mode report filenames (never stored in main DB).
|
|
31
|
+
* Used by remote runner nodes and by the built-in lane when running as part of a
|
|
32
|
+
* distributed job (PLUM_MODE=node blocks the DB write in generate-report.js).
|
|
33
|
+
*/
|
|
34
|
+
const TRIGGER_REMOTE = 'remote-trigger';
|
|
35
|
+
|
|
36
|
+
/** The string ID used throughout the codebase to refer to the built-in runner. */
|
|
37
|
+
const BUILT_IN_RUNNER_ID = 'built-in';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* All trigger values that are NOT a cron task name.
|
|
41
|
+
* Anything outside this set is treated as a scheduled triggerType (i.e. the taskName).
|
|
42
|
+
*/
|
|
43
|
+
const NON_SCHEDULED_TRIGGERS = new Set([
|
|
44
|
+
TRIGGER_TYPE.MANUAL,
|
|
45
|
+
TRIGGER_TYPE.CLI,
|
|
46
|
+
TRIGGER_REMOTE,
|
|
47
|
+
'undefined'
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
/** Returns true when triggerType is a cron job taskName (i.e. a scheduled run). */
|
|
51
|
+
function isScheduledTrigger(triggerType) {
|
|
52
|
+
return !!triggerType && !NON_SCHEDULED_TRIGGERS.has(triggerType);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Normalises a raw TRIGGER env value for DB storage — falls back to CLI if blank. */
|
|
56
|
+
function normaliseTrigger(raw) {
|
|
57
|
+
return raw || TRIGGER_TYPE.CLI;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = {
|
|
61
|
+
TRIGGER_TYPE,
|
|
62
|
+
TRIGGER_REMOTE,
|
|
63
|
+
BUILT_IN_RUNNER_ID,
|
|
64
|
+
NON_SCHEDULED_TRIGGERS,
|
|
65
|
+
isScheduledTrigger,
|
|
66
|
+
normaliseTrigger
|
|
67
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
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 path = require('path');
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
|
|
21
|
+
const REPORTS_DIR = path.resolve(process.cwd(), 'reports');
|
|
22
|
+
const SCREENSHOTS_DIR = path.join(REPORTS_DIR, 'screenshots');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Reads the transient cucumber_report.json written by the most recent local test run.
|
|
26
|
+
* Returns the raw JSON string, or null if the file is absent or unreadable.
|
|
27
|
+
*/
|
|
28
|
+
function readCucumberReportFile() {
|
|
29
|
+
try {
|
|
30
|
+
const p = path.join(REPORTS_DIR, 'cucumber_report.json');
|
|
31
|
+
return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { REPORTS_DIR, SCREENSHOTS_DIR, readCucumberReportFile };
|
|
@@ -0,0 +1,73 @@
|
|
|
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 { getTestSuites } = require('../services/testService');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Returns the primary test ID for every test that matches the given tag.
|
|
22
|
+
* If tag is empty, all test IDs are returned.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} tag Cucumber tag expression (e.g. '@suite-login')
|
|
25
|
+
* @returns {string[]}
|
|
26
|
+
*/
|
|
27
|
+
function getTestIdsForTag(tag) {
|
|
28
|
+
const { suites } = getTestSuites();
|
|
29
|
+
const ids = [];
|
|
30
|
+
const normalTag = tag?.trim();
|
|
31
|
+
|
|
32
|
+
for (const suite of suites) {
|
|
33
|
+
const suiteIds = Array.isArray(suite.suiteId) ? suite.suiteId : [suite.suiteId];
|
|
34
|
+
const suiteMatches = !normalTag || suiteIds.some((id) => id === normalTag);
|
|
35
|
+
|
|
36
|
+
for (const test of suite.tests) {
|
|
37
|
+
const testIds = Array.isArray(test.id) ? test.id : [test.id];
|
|
38
|
+
if (suiteMatches || testIds.some((id) => id === normalTag)) {
|
|
39
|
+
ids.push(testIds[0]);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return ids;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Splits test IDs into N even chunks (one per runner slot).
|
|
49
|
+
* If n <= 1, returns a single chunk containing all IDs.
|
|
50
|
+
*
|
|
51
|
+
* @param {string[]} allIds
|
|
52
|
+
* @param {number} n Number of chunks
|
|
53
|
+
* @returns {string[][]}
|
|
54
|
+
*/
|
|
55
|
+
function chunkTests(allIds, n) {
|
|
56
|
+
if (n <= 1) return [allIds];
|
|
57
|
+
const chunks = Array.from({ length: n }, () => []);
|
|
58
|
+
allIds.forEach((id, i) => chunks[i % n].push(id));
|
|
59
|
+
return chunks.filter((c) => c.length > 0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Builds a Cucumber OR tag expression from an array of test IDs.
|
|
64
|
+
*
|
|
65
|
+
* @param {string[]} ids
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
function buildTagExpression(ids) {
|
|
69
|
+
if (!ids || ids.length === 0) return '';
|
|
70
|
+
return ids.join(' or ');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { getTestIdsForTag, chunkTests, buildTagExpression };
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
* Bearer-token auth guard for node-mode API routes.
|
|
20
|
+
* Passes through when NODE_TOKEN is not configured (open/dev environments).
|
|
21
|
+
*/
|
|
22
|
+
function authGuard(req, res, next) {
|
|
23
|
+
const nodeToken = process.env.NODE_TOKEN;
|
|
24
|
+
if (!nodeToken) return next();
|
|
25
|
+
const auth = req.headers.authorization;
|
|
26
|
+
if (!auth || auth !== `Bearer ${nodeToken}`) {
|
|
27
|
+
return res.status(401).json({ error: 'Unauthorized' });
|
|
28
|
+
}
|
|
29
|
+
next();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { authGuard };
|
package/backend/package.json
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "plum-backend",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"main": "index.js",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"init": "node services/envService.js",
|
|
7
7
|
"create-step": "node config/scripts/create-step.mjs",
|
|
8
8
|
"create-env": "node services/envService.js",
|
|
9
|
-
"test": "node config/scripts/run-tests.js"
|
|
9
|
+
"test": "node config/scripts/run-tests.js",
|
|
10
|
+
"add-local-runner": "node scripts/add-local-runner.js",
|
|
11
|
+
"create-test": "node scripts/create-test.js"
|
|
10
12
|
},
|
|
11
13
|
"keywords": [],
|
|
12
14
|
"author": "",
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
-- CreateTable
|
|
2
|
+
CREATE TABLE "Runner" (
|
|
3
|
+
"id" TEXT NOT NULL,
|
|
4
|
+
"name" TEXT NOT NULL,
|
|
5
|
+
"url" TEXT NOT NULL,
|
|
6
|
+
"token" TEXT NOT NULL,
|
|
7
|
+
"browser" TEXT NOT NULL DEFAULT 'chromium',
|
|
8
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
9
|
+
|
|
10
|
+
CONSTRAINT "Runner_pkey" PRIMARY KEY ("id")
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
-- AlterTable
|
|
14
|
+
ALTER TABLE "CronJob" ADD COLUMN "browser" TEXT NOT NULL DEFAULT 'chromium';
|
|
15
|
+
ALTER TABLE "CronJob" ADD COLUMN "runnerId" TEXT;
|
|
16
|
+
|
|
17
|
+
-- AlterTable
|
|
18
|
+
ALTER TABLE "Report" ADD COLUMN "browser" TEXT NOT NULL DEFAULT 'chromium';
|
|
19
|
+
ALTER TABLE "Report" ADD COLUMN "runnerId" TEXT;
|
|
20
|
+
ALTER TABLE "Report" ADD COLUMN "runnerName" TEXT;
|
|
21
|
+
|
|
22
|
+
-- AddForeignKey
|
|
23
|
+
ALTER TABLE "CronJob" ADD CONSTRAINT "CronJob_runnerId_fkey" FOREIGN KEY ("runnerId") REFERENCES "Runner"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
24
|
+
|
|
25
|
+
-- AddForeignKey
|
|
26
|
+
ALTER TABLE "Report" ADD CONSTRAINT "Report_runnerId_fkey" FOREIGN KEY ("runnerId") REFERENCES "Runner"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
-- Add runnerIds column to store multiple runner IDs as a comma-separated string.
|
|
2
|
+
-- Existing jobs that already have a single runnerId are migrated to it;
|
|
3
|
+
-- everything else defaults to 'built-in'.
|
|
4
|
+
ALTER TABLE "CronJob" ADD COLUMN "runnerIds" TEXT NOT NULL DEFAULT 'built-in';
|
|
5
|
+
|
|
6
|
+
UPDATE "CronJob" SET "runnerIds" = "runnerId" WHERE "runnerId" IS NOT NULL;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE "CronJob" ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT true;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
-- Clear all existing reports (no production data to preserve)
|
|
2
|
+
TRUNCATE TABLE "Report" CASCADE;
|
|
3
|
+
|
|
4
|
+
-- Drop the old filename-based column
|
|
5
|
+
ALTER TABLE "Report" DROP COLUMN "fileName";
|
|
6
|
+
|
|
7
|
+
-- Add the full report content as JSONB
|
|
8
|
+
ALTER TABLE "Report" ADD COLUMN "content" JSONB NOT NULL DEFAULT '{}';
|
|
@@ -23,12 +23,28 @@ datasource db {
|
|
|
23
23
|
url = env("DATABASE_URL")
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
model Runner {
|
|
27
|
+
id String @id @default(cuid())
|
|
28
|
+
name String
|
|
29
|
+
url String
|
|
30
|
+
token String
|
|
31
|
+
browser String @default("chromium")
|
|
32
|
+
createdAt DateTime @default(now())
|
|
33
|
+
cronJobs CronJob[]
|
|
34
|
+
reports Report[]
|
|
35
|
+
}
|
|
36
|
+
|
|
26
37
|
model CronJob {
|
|
27
38
|
id Int @id @default(autoincrement())
|
|
28
39
|
taskName String @unique
|
|
29
40
|
cronExpression String
|
|
30
41
|
tags String
|
|
31
42
|
workers Int @default(1)
|
|
43
|
+
browser String @default("chromium")
|
|
44
|
+
enabled Boolean @default(true)
|
|
45
|
+
runnerId String?
|
|
46
|
+
runnerIds String @default("built-in")
|
|
47
|
+
runner Runner? @relation(fields: [runnerId], references: [id], onDelete: SetNull)
|
|
32
48
|
createdAt DateTime @default(now())
|
|
33
49
|
updatedAt DateTime @updatedAt
|
|
34
50
|
reports Report[]
|
|
@@ -36,13 +52,17 @@ model CronJob {
|
|
|
36
52
|
|
|
37
53
|
model Report {
|
|
38
54
|
id Int @id @default(autoincrement())
|
|
39
|
-
fileName String @unique
|
|
40
55
|
status String
|
|
41
56
|
tags String
|
|
42
57
|
triggerType String @default("manual-trigger")
|
|
43
58
|
runners Int @default(1)
|
|
59
|
+
browser String @default("chromium")
|
|
60
|
+
runnerName String?
|
|
61
|
+
runnerId String?
|
|
62
|
+
runner Runner? @relation(fields: [runnerId], references: [id], onDelete: SetNull)
|
|
44
63
|
cronJobId Int?
|
|
45
64
|
cronJob CronJob? @relation(fields: [cronJobId], references: [id], onDelete: SetNull)
|
|
65
|
+
content Json
|
|
46
66
|
createdAt DateTime @default(now())
|
|
47
67
|
}
|
|
48
68
|
|
|
@@ -62,6 +62,34 @@ router.put('/:taskName', async (req, res) => {
|
|
|
62
62
|
}
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
+
router.patch('/:taskName/toggle', async (req, res) => {
|
|
66
|
+
try {
|
|
67
|
+
const { taskName } = req.params;
|
|
68
|
+
const { enabled } = req.body;
|
|
69
|
+
if (typeof enabled !== 'boolean') {
|
|
70
|
+
return res.status(400).json({ error: 'enabled must be a boolean' });
|
|
71
|
+
}
|
|
72
|
+
const result = await cronService.toggleCronJob(taskName, enabled);
|
|
73
|
+
if (result.status === 404) return res.status(404).json({ error: result.message });
|
|
74
|
+
res.json({ taskName, enabled: result.enabled });
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.error('Error toggling cron job:', error);
|
|
77
|
+
res.status(500).json({ error: 'Failed to toggle cron job' });
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
router.post('/:taskName/run', async (req, res) => {
|
|
82
|
+
try {
|
|
83
|
+
const { taskName } = req.params;
|
|
84
|
+
const result = await cronService.runJobNow(taskName);
|
|
85
|
+
if (result.status === 404) return res.status(404).json({ error: result.message });
|
|
86
|
+
res.json({ message: `Cron job ${taskName} triggered` });
|
|
87
|
+
} catch (error) {
|
|
88
|
+
console.error('Error running cron job:', error);
|
|
89
|
+
res.status(500).json({ error: 'Failed to run cron job' });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
65
93
|
router.delete('/:taskName', async (req, res) => {
|
|
66
94
|
try {
|
|
67
95
|
const { taskName } = req.params;
|