plum-e2e 1.1.1 → 1.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/.claude/settings.local.json +27 -25
- package/.husky/pre-commit +2 -2
- package/README.md +142 -70
- package/backend/Dockerfile +4 -2
- package/backend/app.js +4 -2
- package/backend/config/scripts/generate-report.js +38 -30
- package/backend/entrypoint.sh +22 -0
- package/backend/package-lock.json +453 -10
- package/backend/package.json +5 -2
- package/backend/prisma/migrations/20260614000000_init/migration.sql +35 -0
- package/backend/prisma/migrations/20260614000001_add_project/migration.sql +8 -0
- package/backend/prisma/migrations/migration_lock.toml +3 -0
- package/backend/prisma/schema.prisma +53 -0
- package/backend/routes/backup.routes.js +50 -0
- package/backend/routes/cron.routes.js +9 -60
- package/backend/routes/reports.routes.js +39 -6
- package/backend/routes/settings.routes.js +43 -0
- package/backend/server.js +52 -1
- package/backend/services/backupService.js +88 -0
- package/backend/services/cronService.js +68 -78
- package/backend/services/{scheduleService.js → prisma.js} +3 -15
- package/backend/services/reportService.js +48 -20
- package/backend/{routes/schedules.routes.js → services/settingsService.js} +17 -13
- package/bin/plum.js +213 -32
- package/docker-compose.yml +24 -0
- package/frontend/package-lock.json +2 -2
- package/frontend/package.json +1 -1
- package/frontend/src/lib/api/reports.js +38 -27
- package/frontend/src/lib/api/schedules.js +9 -25
- package/frontend/src/lib/api/settings.js +48 -0
- package/frontend/src/lib/components/layout/Nav.svelte +2 -1
- package/frontend/src/lib/components/layout/RunnerPanel.svelte +160 -21
- package/frontend/src/lib/components/ui/Terminal.svelte +2 -2
- package/frontend/src/lib/stores/runner.js +9 -0
- package/frontend/src/routes/+page.svelte +10 -3
- package/frontend/src/routes/reports/+page.svelte +342 -51
- package/frontend/src/routes/reports/[slug]/+page.svelte +2 -0
- package/frontend/src/routes/scheduled-tests/+page.svelte +247 -11
- package/frontend/src/routes/settings/+page.svelte +410 -0
- package/license-config.json +2 -2
- package/package.json +6 -2
- package/backend/config/scripts/create-settings.js +0 -53
|
@@ -15,113 +15,103 @@
|
|
|
15
15
|
* along with Plum. If not, see https://www.gnu.org/licenses/.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
const fs = require('fs');
|
|
19
|
-
const path = require('path');
|
|
20
18
|
const cron = require('node-cron');
|
|
21
19
|
const { spawn } = require('child_process');
|
|
20
|
+
const prisma = require('./prisma');
|
|
22
21
|
|
|
23
|
-
const
|
|
24
|
-
let
|
|
22
|
+
const scheduledJobs = {};
|
|
23
|
+
let _io = null;
|
|
25
24
|
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
const setSocketIO = (io) => {
|
|
26
|
+
_io = io;
|
|
27
|
+
};
|
|
29
28
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
function scheduleJob(taskName, cronExpression, tags, workers) {
|
|
30
|
+
if (scheduledJobs[taskName]) {
|
|
31
|
+
scheduledJobs[taskName].stop();
|
|
32
|
+
delete scheduledJobs[taskName];
|
|
33
|
+
}
|
|
35
34
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
scheduledJobs[taskName] = cron.schedule(cronExpression, () => {
|
|
36
|
+
console.log(`Running scheduled task: ${taskName}`);
|
|
37
|
+
if (_io) _io.emit('cron-start', { taskName });
|
|
39
38
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
task.on('close', (code) => console.log(`Task ${taskName} finished with code ${code}`));
|
|
43
|
-
});
|
|
39
|
+
const env = { ...process.env, TAG: tags, TRIGGER: taskName };
|
|
40
|
+
if (workers && workers > 1) env.PARALLEL = String(workers);
|
|
44
41
|
|
|
45
|
-
|
|
46
|
-
|
|
42
|
+
const task = spawn('npm', ['run', 'test'], { env });
|
|
43
|
+
task.stdout.on('data', (data) => console.log(data.toString()));
|
|
44
|
+
task.stderr.on('data', (data) => console.error(data.toString()));
|
|
45
|
+
task.on('close', (code) => {
|
|
46
|
+
console.log(`Task ${taskName} finished with code ${code}`);
|
|
47
|
+
if (_io) _io.emit('cron-done', { taskName, code });
|
|
47
48
|
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const init = async () => {
|
|
53
|
+
const jobs = await prisma.cronJob.findMany();
|
|
54
|
+
for (const job of jobs) {
|
|
55
|
+
scheduleJob(job.taskName, job.cronExpression, job.tags, job.workers);
|
|
48
56
|
}
|
|
57
|
+
console.log(`⏰ Scheduled ${jobs.length} cron job(s) from database`);
|
|
49
58
|
};
|
|
50
59
|
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}, {});
|
|
58
|
-
|
|
59
|
-
fs.writeFileSync(CRON_JOBS_FILE, JSON.stringify(cronJobsData, null, 2), 'utf8');
|
|
60
|
+
const reload = async () => {
|
|
61
|
+
for (const name of Object.keys(scheduledJobs)) {
|
|
62
|
+
scheduledJobs[name].stop();
|
|
63
|
+
delete scheduledJobs[name];
|
|
64
|
+
}
|
|
65
|
+
await init();
|
|
60
66
|
};
|
|
61
67
|
|
|
62
|
-
const getAllCronJobs = () =>
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
cronExpression: cronJobs[taskName].cronExpression,
|
|
66
|
-
tags: cronJobs[taskName].tags
|
|
67
|
-
}));
|
|
68
|
+
const getAllCronJobs = async () => {
|
|
69
|
+
return prisma.cronJob.findMany({ orderBy: { createdAt: 'asc' } });
|
|
70
|
+
};
|
|
68
71
|
|
|
69
|
-
const addCronJob = ({
|
|
72
|
+
const addCronJob = async ({ taskName, cronExpression, tags, workers }) => {
|
|
70
73
|
if (!cronExpression || !taskName || !tags) {
|
|
71
74
|
return { status: 400, message: 'Missing required parameters' };
|
|
72
75
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
const job = await prisma.cronJob.create({
|
|
77
|
+
data: { taskName, cronExpression, tags, workers: workers ?? 1 }
|
|
78
|
+
});
|
|
79
|
+
scheduleJob(job.taskName, job.cronExpression, job.tags, job.workers);
|
|
77
80
|
return { status: 201, message: `Cron job ${taskName} added` };
|
|
78
81
|
};
|
|
79
82
|
|
|
80
|
-
const removeCronJob = (taskName) => {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
83
|
+
const removeCronJob = async (taskName) => {
|
|
84
|
+
const job = await prisma.cronJob.findUnique({ where: { taskName } });
|
|
85
|
+
if (!job) return { status: 404, message: `Cron job ${taskName} not found` };
|
|
84
86
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
+
if (scheduledJobs[taskName]) {
|
|
88
|
+
scheduledJobs[taskName].stop();
|
|
89
|
+
delete scheduledJobs[taskName];
|
|
90
|
+
}
|
|
87
91
|
|
|
88
|
-
delete
|
|
89
|
-
saveCronJobs();
|
|
90
|
-
loadCronJobs(); // Re-load and re-schedule cron jobs without the removed one
|
|
92
|
+
await prisma.cronJob.delete({ where: { taskName } });
|
|
91
93
|
return { status: 200, message: `Cron job ${taskName} deleted` };
|
|
92
94
|
};
|
|
93
95
|
|
|
94
|
-
const updateCronJob = (taskName, { cronExpression, tags }) => {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Stop the old cron job
|
|
100
|
-
cronJobs[taskName].cronJob.stop();
|
|
101
|
-
|
|
102
|
-
// Update the cron job with new values
|
|
103
|
-
cronJobs[taskName] = { cronExpression, tags };
|
|
104
|
-
|
|
105
|
-
// Reschedule the updated cron job and store the reference
|
|
106
|
-
const scheduledCronJob = cron.schedule(cronExpression, () => {
|
|
107
|
-
console.log(`Running updated task: ${taskName}`);
|
|
108
|
-
|
|
109
|
-
const task = spawn('npm', ['run', 'test'], {
|
|
110
|
-
env: { ...process.env, TAG: tags, TRIGGER: taskName }
|
|
111
|
-
});
|
|
96
|
+
const updateCronJob = async (taskName, { cronExpression, tags, workers }) => {
|
|
97
|
+
const job = await prisma.cronJob.findUnique({ where: { taskName } });
|
|
98
|
+
if (!job) return { status: 404, message: `Cron job ${taskName} not found` };
|
|
112
99
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
100
|
+
const updated = await prisma.cronJob.update({
|
|
101
|
+
where: { taskName },
|
|
102
|
+
data: { cronExpression, tags, workers: workers ?? 1 }
|
|
116
103
|
});
|
|
117
104
|
|
|
118
|
-
|
|
119
|
-
cronJobs[taskName].cronJob = scheduledCronJob;
|
|
120
|
-
|
|
121
|
-
saveCronJobs(); // Save the updated cron jobs to file (excluding cron job references)
|
|
105
|
+
scheduleJob(updated.taskName, updated.cronExpression, updated.tags, updated.workers);
|
|
122
106
|
return { status: 200, message: `Cron job ${taskName} updated` };
|
|
123
107
|
};
|
|
124
108
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
109
|
+
module.exports = {
|
|
110
|
+
init,
|
|
111
|
+
reload,
|
|
112
|
+
getAllCronJobs,
|
|
113
|
+
addCronJob,
|
|
114
|
+
removeCronJob,
|
|
115
|
+
updateCronJob,
|
|
116
|
+
setSocketIO
|
|
117
|
+
};
|
|
@@ -15,20 +15,8 @@
|
|
|
15
15
|
* along with Plum. If not, see https://www.gnu.org/licenses/.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
const
|
|
19
|
-
const path = require('path');
|
|
18
|
+
const { PrismaClient } = require('@prisma/client');
|
|
20
19
|
|
|
21
|
-
const
|
|
20
|
+
const prisma = new PrismaClient();
|
|
22
21
|
|
|
23
|
-
|
|
24
|
-
try {
|
|
25
|
-
const fileContent = fs.readFileSync(SETTINGS_PATH, 'utf8');
|
|
26
|
-
const settings = JSON.parse(fileContent);
|
|
27
|
-
return settings.cronJobSchedules || [];
|
|
28
|
-
} catch (error) {
|
|
29
|
-
console.error('Error reading or parsing the settings file:', error);
|
|
30
|
-
return [];
|
|
31
|
-
}
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
module.exports = { getAllSchedules };
|
|
22
|
+
module.exports = prisma;
|
|
@@ -17,29 +17,36 @@
|
|
|
17
17
|
|
|
18
18
|
const fs = require('fs');
|
|
19
19
|
const path = require('path');
|
|
20
|
+
const prisma = require('./prisma');
|
|
20
21
|
|
|
21
22
|
const REPORTS_DIR = path.join(__dirname, '../reports');
|
|
22
23
|
|
|
23
|
-
const getAllReports = () => {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const bt = fs.statSync(path.join(REPORTS_DIR, b)).mtime;
|
|
31
|
-
return bt - at;
|
|
32
|
-
});
|
|
24
|
+
const getAllReports = async () => {
|
|
25
|
+
return prisma.report.findMany({ orderBy: { createdAt: 'desc' } });
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const getLatestReport = async () => {
|
|
29
|
+
const report = await prisma.report.findFirst({ orderBy: { createdAt: 'desc' } });
|
|
30
|
+
return report ? report.fileName : null;
|
|
33
31
|
};
|
|
34
32
|
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
const saveReport = async ({ fileName, status, tags, triggerType, runners }) => {
|
|
34
|
+
let cronJobId = null;
|
|
35
|
+
const builtInTriggers = ['manual-trigger', 'command-line-trigger', 'undefined'];
|
|
36
|
+
if (triggerType && !builtInTriggers.includes(triggerType)) {
|
|
37
|
+
const job = await prisma.cronJob.findUnique({ where: { taskName: triggerType } });
|
|
38
|
+
if (job) cronJobId = job.id;
|
|
39
|
+
}
|
|
40
|
+
return prisma.report.upsert({
|
|
41
|
+
where: { fileName },
|
|
42
|
+
create: { fileName, status, tags, triggerType: triggerType || 'undefined', runners, cronJobId },
|
|
43
|
+
update: {}
|
|
44
|
+
});
|
|
38
45
|
};
|
|
39
46
|
|
|
40
47
|
const getReportDetail = (fileName) => {
|
|
41
48
|
const filePath = path.join(REPORTS_DIR, fileName);
|
|
42
|
-
if (!filePath.startsWith(REPORTS_DIR)) return null;
|
|
49
|
+
if (!filePath.startsWith(REPORTS_DIR)) return null;
|
|
43
50
|
if (!fs.existsSync(filePath)) return null;
|
|
44
51
|
|
|
45
52
|
let raw;
|
|
@@ -58,11 +65,11 @@ const getReportDetail = (fileName) => {
|
|
|
58
65
|
const failedStepIndex = visibleSteps.findLastIndex((s) => s.result?.status === 'failed');
|
|
59
66
|
|
|
60
67
|
const steps = visibleSteps.map((step, index) => ({
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
68
|
+
keyword: step.keyword.trim(),
|
|
69
|
+
name: step.name ?? '',
|
|
70
|
+
status: step.result?.status ?? 'pending',
|
|
71
|
+
duration: Math.round((step.result?.duration ?? 0) / 1_000_000),
|
|
72
|
+
error: step.result?.error_message ?? null,
|
|
66
73
|
screenshot:
|
|
67
74
|
step.embeddings?.find((e) => e.mime_type === 'image/png')?.data ??
|
|
68
75
|
(index === failedStepIndex ? hookScreenshots[0]?.data : null) ??
|
|
@@ -97,4 +104,25 @@ const getReportDetail = (fileName) => {
|
|
|
97
104
|
return { features };
|
|
98
105
|
};
|
|
99
106
|
|
|
100
|
-
|
|
107
|
+
const deleteReport = async (fileName) => {
|
|
108
|
+
await prisma.report.delete({ where: { fileName } });
|
|
109
|
+
const filePath = path.join(REPORTS_DIR, fileName);
|
|
110
|
+
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const deleteReports = async (fileNames) => {
|
|
114
|
+
await prisma.report.deleteMany({ where: { fileName: { in: fileNames } } });
|
|
115
|
+
for (const fileName of fileNames) {
|
|
116
|
+
const filePath = path.join(REPORTS_DIR, fileName);
|
|
117
|
+
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
module.exports = {
|
|
122
|
+
getAllReports,
|
|
123
|
+
getLatestReport,
|
|
124
|
+
saveReport,
|
|
125
|
+
getReportDetail,
|
|
126
|
+
deleteReport,
|
|
127
|
+
deleteReports
|
|
128
|
+
};
|
|
@@ -15,18 +15,22 @@
|
|
|
15
15
|
* along with Plum. If not, see https://www.gnu.org/licenses/.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
const
|
|
19
|
-
const router = express.Router();
|
|
20
|
-
const scheduleService = require('../services/scheduleService');
|
|
18
|
+
const prisma = require('./prisma');
|
|
21
19
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
res.json({ schedules });
|
|
30
|
-
});
|
|
20
|
+
const getProject = async () => {
|
|
21
|
+
let project = await prisma.project.findUnique({ where: { id: 1 } });
|
|
22
|
+
if (!project) {
|
|
23
|
+
project = await prisma.project.create({ data: { id: 1, name: '', logoUrl: '' } });
|
|
24
|
+
}
|
|
25
|
+
return project;
|
|
26
|
+
};
|
|
31
27
|
|
|
32
|
-
|
|
28
|
+
const updateProject = async ({ name, logoUrl }) => {
|
|
29
|
+
return prisma.project.upsert({
|
|
30
|
+
where: { id: 1 },
|
|
31
|
+
create: { id: 1, name: name ?? '', logoUrl: logoUrl ?? '' },
|
|
32
|
+
update: { name: name ?? '', logoUrl: logoUrl ?? '' }
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
module.exports = { getProject, updateProject };
|
package/bin/plum.js
CHANGED
|
@@ -55,6 +55,70 @@ IS_HEADLESS=false
|
|
|
55
55
|
console.log('✅ .env file created with default values.\n');
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// Scaffold plum.plugins.json if it doesn't exist yet
|
|
59
|
+
function scaffoldPluginsFile() {
|
|
60
|
+
const pluginsPath = path.join(process.cwd(), 'plum.plugins.json');
|
|
61
|
+
if (fs.existsSync(pluginsPath)) {
|
|
62
|
+
console.log('⚠️ plum.plugins.json already exists. Skipping.\n');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const content = {
|
|
66
|
+
'//': 'Add npm packages your tests depend on. Plum installs them automatically before each run.',
|
|
67
|
+
'// example':
|
|
68
|
+
'To add a package: put its name and version under "dependencies", e.g. "@faker-js/faker": "^9.0.0"',
|
|
69
|
+
dependencies: {}
|
|
70
|
+
};
|
|
71
|
+
fs.writeFileSync(pluginsPath, JSON.stringify(content, null, 2) + '\n', 'utf8');
|
|
72
|
+
console.log('✅ plum.plugins.json created. Add npm packages here to extend your tests.\n');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Install user plugins listed in plum.plugins.json into the backend
|
|
76
|
+
function installPlugins() {
|
|
77
|
+
const pluginsPath = path.join(process.cwd(), 'plum.plugins.json');
|
|
78
|
+
if (!fs.existsSync(pluginsPath)) return;
|
|
79
|
+
|
|
80
|
+
let plugins;
|
|
81
|
+
try {
|
|
82
|
+
plugins = JSON.parse(fs.readFileSync(pluginsPath, 'utf8'));
|
|
83
|
+
} catch {
|
|
84
|
+
console.log('⚠️ Could not parse plum.plugins.json. Skipping plugin install.\n');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const deps = plugins.dependencies ?? {};
|
|
89
|
+
const packages = Object.entries(deps).map(([name, version]) => `${name}@${version}`);
|
|
90
|
+
if (packages.length === 0) return;
|
|
91
|
+
|
|
92
|
+
console.log(`📦 Installing plugins: ${packages.join(', ')}\n`);
|
|
93
|
+
execSync(`npm install ${packages.join(' ')}`, {
|
|
94
|
+
cwd: path.join(plumRoot, 'backend'),
|
|
95
|
+
stdio: 'inherit'
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Ensure user's .gitignore contains Plum-generated entries
|
|
100
|
+
function ensureGitignore() {
|
|
101
|
+
const gitignorePath = path.join(process.cwd(), '.gitignore');
|
|
102
|
+
const plumEntries = ['.plum/', 'reports/'];
|
|
103
|
+
const plumBlock = `\n# Plum (auto-generated)\n${plumEntries.join('\n')}\n`;
|
|
104
|
+
|
|
105
|
+
if (!fs.existsSync(gitignorePath)) {
|
|
106
|
+
fs.writeFileSync(gitignorePath, plumBlock.trimStart(), 'utf8');
|
|
107
|
+
console.log('✅ .gitignore created with Plum entries.\n');
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const existing = fs.readFileSync(gitignorePath, 'utf8');
|
|
112
|
+
const missing = plumEntries.filter((e) => !existing.includes(e));
|
|
113
|
+
if (missing.length === 0) {
|
|
114
|
+
console.log('⚠️ .gitignore already contains Plum entries. Skipping.\n');
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
fs.appendFileSync(gitignorePath, `\n# Plum (auto-generated)\n${missing.join('\n')}\n`);
|
|
119
|
+
console.log('✅ .gitignore updated with Plum entries.\n');
|
|
120
|
+
}
|
|
121
|
+
|
|
58
122
|
// Function to copy .env file from root to backend
|
|
59
123
|
function copyEnvFile() {
|
|
60
124
|
try {
|
|
@@ -91,32 +155,112 @@ switch (command) {
|
|
|
91
155
|
// Create .env file with default values
|
|
92
156
|
createEnvFile();
|
|
93
157
|
|
|
94
|
-
// Create
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
158
|
+
// Create or update .gitignore with Plum-generated paths
|
|
159
|
+
ensureGitignore();
|
|
160
|
+
|
|
161
|
+
// Scaffold plum.plugins.json for user-managed dependencies
|
|
162
|
+
scaffoldPluginsFile();
|
|
163
|
+
|
|
164
|
+
// Create .vscode/settings.json and install Cucumber extension — only if VS Code is available
|
|
165
|
+
{
|
|
166
|
+
let vscodeAvailable = false;
|
|
167
|
+
try {
|
|
168
|
+
execSync('code --version', { stdio: 'ignore' });
|
|
169
|
+
vscodeAvailable = true;
|
|
170
|
+
} catch {}
|
|
171
|
+
|
|
172
|
+
if (vscodeAvailable) {
|
|
173
|
+
const vscodeSettingsPath = path.join(process.cwd(), '.vscode', 'settings.json');
|
|
174
|
+
if (!fs.existsSync(vscodeSettingsPath)) {
|
|
175
|
+
fs.mkdirSync(path.dirname(vscodeSettingsPath), { recursive: true });
|
|
176
|
+
fs.writeFileSync(
|
|
177
|
+
vscodeSettingsPath,
|
|
178
|
+
JSON.stringify(
|
|
179
|
+
{
|
|
180
|
+
'cucumber.glue': ['tests/step_definitions/**/*.ts'],
|
|
181
|
+
'cucumber.features': ['tests/features/**/*.feature']
|
|
182
|
+
},
|
|
183
|
+
null,
|
|
184
|
+
2
|
|
185
|
+
) + '\n',
|
|
186
|
+
'utf8'
|
|
187
|
+
);
|
|
188
|
+
console.log('✅ .vscode/settings.json created for Cucumber extension.\n');
|
|
189
|
+
} else {
|
|
190
|
+
console.log('⚠️ .vscode/settings.json already exists. Skipping.\n');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
execSync('code --install-extension cucumberopen.cucumber-official', { stdio: 'inherit' });
|
|
195
|
+
console.log('✅ Cucumber VS Code extension installed.\n');
|
|
196
|
+
} catch {
|
|
197
|
+
console.log(
|
|
198
|
+
'⚠️ Could not install VS Code extension automatically. Install manually: cucumberopen.cucumber-official\n'
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
console.log('ℹ️ VS Code not detected — skipping .vscode setup.\n');
|
|
203
|
+
}
|
|
110
204
|
}
|
|
111
205
|
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
206
|
+
// Create README.md in user's project if one doesn't exist
|
|
207
|
+
{
|
|
208
|
+
const userReadmePath = path.join(process.cwd(), 'README.md');
|
|
209
|
+
if (!fs.existsSync(userReadmePath)) {
|
|
210
|
+
const readmeContent = [
|
|
211
|
+
'# My Tests',
|
|
212
|
+
'',
|
|
213
|
+
'Powered by [Plum](https://github.com/silverlunah/plum) — Playwright + Cucumber.',
|
|
214
|
+
'',
|
|
215
|
+
'## Commands',
|
|
216
|
+
'',
|
|
217
|
+
'| Command | Description |',
|
|
218
|
+
'|---|---|',
|
|
219
|
+
'| `plum dev` | Run all tests locally |',
|
|
220
|
+
'| `plum dev @tag` | Run tests matching a tag |',
|
|
221
|
+
'| `plum dev --parallel N` | Run tests across N parallel workers |',
|
|
222
|
+
'| `plum start` | Start the full UI via Docker (`http://localhost:5173`) |',
|
|
223
|
+
'| `plum create-step` | Interactively generate a new step definition |',
|
|
224
|
+
'',
|
|
225
|
+
'## Configuration',
|
|
226
|
+
'',
|
|
227
|
+
'| File | Purpose |',
|
|
228
|
+
'|---|---|',
|
|
229
|
+
'| `.env` | Set `BASE_URL` and `IS_HEADLESS` |',
|
|
230
|
+
'| `plum.plugins.json` | Add extra npm packages for your tests |',
|
|
231
|
+
'',
|
|
232
|
+
'## Test Structure',
|
|
233
|
+
'',
|
|
234
|
+
'```',
|
|
235
|
+
'tests/',
|
|
236
|
+
' features/ — Gherkin .feature files',
|
|
237
|
+
' step_definitions/ — TypeScript step implementations',
|
|
238
|
+
' pages/ — Page Object Models',
|
|
239
|
+
' utils/ — Browser setup, hooks, helpers',
|
|
240
|
+
'```',
|
|
241
|
+
'',
|
|
242
|
+
'Tags are used to filter which tests to run:',
|
|
243
|
+
'',
|
|
244
|
+
'```gherkin',
|
|
245
|
+
'@suite-login',
|
|
246
|
+
'Feature: Login',
|
|
247
|
+
'',
|
|
248
|
+
' @test-login-1',
|
|
249
|
+
' Scenario: User can log in',
|
|
250
|
+
' Given I am on the login page',
|
|
251
|
+
' ...',
|
|
252
|
+
'```',
|
|
253
|
+
'',
|
|
254
|
+
'```bash',
|
|
255
|
+
'plum dev @test-login-1 # single scenario',
|
|
256
|
+
'plum dev @suite-login # whole suite',
|
|
257
|
+
'```'
|
|
258
|
+
].join('\n');
|
|
259
|
+
fs.writeFileSync(userReadmePath, readmeContent + '\n', 'utf8');
|
|
260
|
+
console.log('✅ README.md created with command reference.\n');
|
|
261
|
+
} else {
|
|
262
|
+
console.log('⚠️ README.md already exists. Skipping.\n');
|
|
263
|
+
}
|
|
120
264
|
}
|
|
121
265
|
|
|
122
266
|
// Initialize project
|
|
@@ -128,7 +272,7 @@ switch (command) {
|
|
|
128
272
|
});
|
|
129
273
|
|
|
130
274
|
console.log(
|
|
131
|
-
'🟣 Plum is now ready!\n\n Scaffold test cases are
|
|
275
|
+
'🟣 Plum is now ready!\n\n Scaffold test cases are in `tests/`.\n Add extra npm packages to `plum.plugins.json`.\n\n - Run tests locally:\n `plum dev` or `plum dev @tag`\n\n - Start the full UI (requires Docker):\n `plum start`\n\n - Generate a step:\n `plum create-step`'
|
|
132
276
|
);
|
|
133
277
|
console.log('--------------------------------------\n');
|
|
134
278
|
break;
|
|
@@ -141,13 +285,32 @@ switch (command) {
|
|
|
141
285
|
// Copy .env file from root to backend
|
|
142
286
|
copyEnvFile();
|
|
143
287
|
|
|
288
|
+
// Merge user plugins into backend/package.json before Docker build
|
|
289
|
+
{
|
|
290
|
+
const userPluginsPath = path.join(process.cwd(), 'plum.plugins.json');
|
|
291
|
+
if (fs.existsSync(userPluginsPath)) {
|
|
292
|
+
try {
|
|
293
|
+
const userPlugins = JSON.parse(fs.readFileSync(userPluginsPath, 'utf8'));
|
|
294
|
+
const backendPkgPath = path.join(plumRoot, 'backend', 'package.json');
|
|
295
|
+
const backendPkg = JSON.parse(fs.readFileSync(backendPkgPath, 'utf8'));
|
|
296
|
+
const pluginDeps = userPlugins.dependencies ?? {};
|
|
297
|
+
if (Object.keys(pluginDeps).length > 0) {
|
|
298
|
+
backendPkg.dependencies = { ...backendPkg.dependencies, ...pluginDeps };
|
|
299
|
+
fs.writeFileSync(backendPkgPath, JSON.stringify(backendPkg, null, '\t') + '\n', 'utf8');
|
|
300
|
+
console.log(`📦 Merged plugins into backend: ${Object.keys(pluginDeps).join(', ')}\n`);
|
|
301
|
+
}
|
|
302
|
+
} catch {
|
|
303
|
+
console.log('⚠️ Could not read plum.plugins.json. Skipping plugin merge.\n');
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
144
308
|
// Copy config from package root to user's project dir so Docker can mount it
|
|
145
309
|
const userConfigPath = path.join(process.cwd(), '.plum', 'config');
|
|
146
310
|
fse.copySync(path.join(plumRoot, 'backend', 'config'), userConfigPath);
|
|
147
311
|
|
|
148
312
|
// Convert Windows paths to safe format
|
|
149
313
|
const userTestsAbs = path.resolve(process.cwd(), 'tests').replace(/\\/g, '/');
|
|
150
|
-
const userModulesAbs = path.resolve(process.cwd(), 'node_modules').replace(/\\/g, '/');
|
|
151
314
|
const userReportsAbs = path.resolve(process.cwd(), 'reports').replace(/\\/g, '/');
|
|
152
315
|
const userConfigAbs = userConfigPath.replace(/\\/g, '/');
|
|
153
316
|
|
|
@@ -158,15 +321,14 @@ switch (command) {
|
|
|
158
321
|
' volumes:',
|
|
159
322
|
` - "${userReportsAbs}:/app/reports"`,
|
|
160
323
|
` - "${userConfigAbs}:/app/config"`,
|
|
161
|
-
` - "${userTestsAbs}:/app/tests"
|
|
162
|
-
` - "${userModulesAbs}:/app/tests/node_modules"`
|
|
324
|
+
` - "${userTestsAbs}:/app/tests"`
|
|
163
325
|
].join('\n');
|
|
164
326
|
|
|
165
327
|
fs.writeFileSync(overrideFilePath, overrideYAML + '\n', 'utf8');
|
|
166
328
|
console.log('✅ docker-compose.override.yml written');
|
|
167
329
|
|
|
168
|
-
// Run docker compose
|
|
169
|
-
execSync('docker compose up', {
|
|
330
|
+
// Run docker compose (--build picks up any plugin or config changes)
|
|
331
|
+
execSync('docker compose up --build', {
|
|
170
332
|
cwd: plumRoot,
|
|
171
333
|
stdio: 'inherit'
|
|
172
334
|
});
|
|
@@ -204,6 +366,9 @@ switch (command) {
|
|
|
204
366
|
stdio: 'inherit'
|
|
205
367
|
});
|
|
206
368
|
|
|
369
|
+
// Install user-defined plugins from plum.plugins.json
|
|
370
|
+
installPlugins();
|
|
371
|
+
|
|
207
372
|
console.log('Running `npx playwright install`...');
|
|
208
373
|
|
|
209
374
|
execSync('npx playwright install', {
|
|
@@ -232,6 +397,17 @@ switch (command) {
|
|
|
232
397
|
break;
|
|
233
398
|
}
|
|
234
399
|
|
|
400
|
+
case 'stop':
|
|
401
|
+
console.log('--------------------------------------\n');
|
|
402
|
+
console.log('🛑 Stopping Plum...');
|
|
403
|
+
execSync('docker compose down', {
|
|
404
|
+
cwd: plumRoot,
|
|
405
|
+
stdio: 'inherit'
|
|
406
|
+
});
|
|
407
|
+
console.log('✅ Plum stopped. Your data is preserved in the database volume.\n');
|
|
408
|
+
console.log('--------------------------------------\n');
|
|
409
|
+
break;
|
|
410
|
+
|
|
235
411
|
case 'create-step': {
|
|
236
412
|
const createStepScript = path.join(plumRoot, 'backend', 'config', 'scripts', 'create-step.mjs');
|
|
237
413
|
execSync(`node ${createStepScript}`, {
|
|
@@ -247,6 +423,11 @@ switch (command) {
|
|
|
247
423
|
|
|
248
424
|
default:
|
|
249
425
|
console.log('--------------------------------------\n');
|
|
250
|
-
console.log('Usage: plum <
|
|
251
|
-
console.log('
|
|
426
|
+
console.log('Usage: plum <command>\n');
|
|
427
|
+
console.log(' init Set up a new Plum project');
|
|
428
|
+
console.log(' start Start the full UI stack via Docker');
|
|
429
|
+
console.log(' stop Stop Docker containers (data is preserved)');
|
|
430
|
+
console.log(' dev Run tests locally without Docker');
|
|
431
|
+
console.log(' create-step Interactively scaffold a new step definition');
|
|
432
|
+
console.log('\n--------------------------------------\n');
|
|
252
433
|
}
|