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.
Files changed (42) hide show
  1. package/.claude/settings.local.json +27 -25
  2. package/.husky/pre-commit +2 -2
  3. package/README.md +142 -70
  4. package/backend/Dockerfile +4 -2
  5. package/backend/app.js +4 -2
  6. package/backend/config/scripts/generate-report.js +38 -30
  7. package/backend/entrypoint.sh +22 -0
  8. package/backend/package-lock.json +453 -10
  9. package/backend/package.json +5 -2
  10. package/backend/prisma/migrations/20260614000000_init/migration.sql +35 -0
  11. package/backend/prisma/migrations/20260614000001_add_project/migration.sql +8 -0
  12. package/backend/prisma/migrations/migration_lock.toml +3 -0
  13. package/backend/prisma/schema.prisma +53 -0
  14. package/backend/routes/backup.routes.js +50 -0
  15. package/backend/routes/cron.routes.js +9 -60
  16. package/backend/routes/reports.routes.js +39 -6
  17. package/backend/routes/settings.routes.js +43 -0
  18. package/backend/server.js +52 -1
  19. package/backend/services/backupService.js +88 -0
  20. package/backend/services/cronService.js +68 -78
  21. package/backend/services/{scheduleService.js → prisma.js} +3 -15
  22. package/backend/services/reportService.js +48 -20
  23. package/backend/{routes/schedules.routes.js → services/settingsService.js} +17 -13
  24. package/bin/plum.js +213 -32
  25. package/docker-compose.yml +24 -0
  26. package/frontend/package-lock.json +2 -2
  27. package/frontend/package.json +1 -1
  28. package/frontend/src/lib/api/reports.js +38 -27
  29. package/frontend/src/lib/api/schedules.js +9 -25
  30. package/frontend/src/lib/api/settings.js +48 -0
  31. package/frontend/src/lib/components/layout/Nav.svelte +2 -1
  32. package/frontend/src/lib/components/layout/RunnerPanel.svelte +160 -21
  33. package/frontend/src/lib/components/ui/Terminal.svelte +2 -2
  34. package/frontend/src/lib/stores/runner.js +9 -0
  35. package/frontend/src/routes/+page.svelte +10 -3
  36. package/frontend/src/routes/reports/+page.svelte +342 -51
  37. package/frontend/src/routes/reports/[slug]/+page.svelte +2 -0
  38. package/frontend/src/routes/scheduled-tests/+page.svelte +247 -11
  39. package/frontend/src/routes/settings/+page.svelte +410 -0
  40. package/license-config.json +2 -2
  41. package/package.json +6 -2
  42. 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 CRON_JOBS_FILE = path.join(__dirname, '../config/cron-jobs.json');
24
- let cronJobs = {};
22
+ const scheduledJobs = {};
23
+ let _io = null;
25
24
 
26
- const loadCronJobs = () => {
27
- if (fs.existsSync(CRON_JOBS_FILE)) {
28
- cronJobs = JSON.parse(fs.readFileSync(CRON_JOBS_FILE, 'utf8'));
25
+ const setSocketIO = (io) => {
26
+ _io = io;
27
+ };
29
28
 
30
- // Schedule all loaded cron jobs and store only necessary data in memory
31
- Object.keys(cronJobs).forEach((taskName) => {
32
- const { cronExpression, tags } = cronJobs[taskName];
33
- const scheduledCronJob = cron.schedule(cronExpression, () => {
34
- console.log(`Running new task: ${taskName}`);
29
+ function scheduleJob(taskName, cronExpression, tags, workers) {
30
+ if (scheduledJobs[taskName]) {
31
+ scheduledJobs[taskName].stop();
32
+ delete scheduledJobs[taskName];
33
+ }
35
34
 
36
- const task = spawn('npm', ['run', 'test'], {
37
- env: { ...process.env, TAG: tags, TRIGGER: taskName }
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
- task.stdout.on('data', (data) => console.log(data.toString()));
41
- task.stderr.on('data', (data) => console.error(data.toString()));
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
- // Store the reference to the cron job only in memory
46
- cronJobs[taskName].cronJob = scheduledCronJob;
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 saveCronJobs = () => {
52
- // Save only cron job data (not the reference to the cron job object) to the file
53
- const cronJobsData = Object.keys(cronJobs).reduce((acc, taskName) => {
54
- const { cronExpression, tags } = cronJobs[taskName];
55
- acc[taskName] = { cronExpression, tags }; // Exclude the cronJob reference
56
- return acc;
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
- Object.keys(cronJobs).map((taskName) => ({
64
- taskName,
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 = ({ cronExpression, taskName, tags }) => {
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
- cronJobs[taskName] = { cronExpression, tags };
75
- saveCronJobs();
76
- loadCronJobs(); // Re-load and schedule the new cron job
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
- if (!cronJobs[taskName]) {
82
- return { status: 404, message: `Cron job ${taskName} not found` };
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
- // Stop the cron job before removing
86
- cronJobs[taskName].cronJob.stop();
87
+ if (scheduledJobs[taskName]) {
88
+ scheduledJobs[taskName].stop();
89
+ delete scheduledJobs[taskName];
90
+ }
87
91
 
88
- delete cronJobs[taskName];
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
- if (!cronJobs[taskName]) {
96
- return { status: 404, message: `Cron job ${taskName} not found` };
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
- task.stdout.on('data', (data) => console.log(data.toString()));
114
- task.stderr.on('data', (data) => console.error(data.toString()));
115
- task.on('close', (code) => console.log(`Task ${taskName} finished with code ${code}`));
100
+ const updated = await prisma.cronJob.update({
101
+ where: { taskName },
102
+ data: { cronExpression, tags, workers: workers ?? 1 }
116
103
  });
117
104
 
118
- // Store the new cron job reference in memory
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
- loadCronJobs(); // Initial load and scheduling of cron jobs
126
-
127
- module.exports = { getAllCronJobs, addCronJob, removeCronJob, updateCronJob };
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 fs = require('fs');
19
- const path = require('path');
18
+ const { PrismaClient } = require('@prisma/client');
20
19
 
21
- const SETTINGS_PATH = path.join(__dirname, '../config/settings.json');
20
+ const prisma = new PrismaClient();
22
21
 
23
- const getAllSchedules = () => {
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
- const files = fs.readdirSync(REPORTS_DIR);
25
- const jsonFiles = files.filter(
26
- (f) => f.endsWith('.json') && (f.startsWith('PASS_') || f.startsWith('FAIL_'))
27
- );
28
- return jsonFiles.sort((a, b) => {
29
- const at = fs.statSync(path.join(REPORTS_DIR, a)).mtime;
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 getLatestReport = () => {
36
- const files = getAllReports();
37
- return files.length ? files[0] : null;
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; // path traversal guard
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
- keyword: step.keyword.trim(),
62
- name: step.name ?? '',
63
- status: step.result?.status ?? 'pending',
64
- duration: Math.round((step.result?.duration ?? 0) / 1_000_000), // ns → ms
65
- error: step.result?.error_message ?? null,
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
- module.exports = { getAllReports, getLatestReport, getReportDetail };
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 express = require('express');
19
- const router = express.Router();
20
- const scheduleService = require('../services/scheduleService');
18
+ const prisma = require('./prisma');
21
19
 
22
- /* -----------------------------------------------------
23
- * Get Schedules
24
- * Description:
25
- * Get all schedules from schedules/
26
- * ------------------------------------------------------ */
27
- router.get('/', (req, res) => {
28
- const schedules = scheduleService.getAllSchedules();
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
- module.exports = router;
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 .vscode/settings.json for Cucumber extension step linkage
95
- const vscodeSettingsPath = path.join(process.cwd(), '.vscode', 'settings.json');
96
- if (!fs.existsSync(vscodeSettingsPath)) {
97
- fs.mkdirSync(path.dirname(vscodeSettingsPath), { recursive: true });
98
- const vscodeSettings = JSON.stringify(
99
- {
100
- 'cucumber.glue': ['tests/step_definitions/**/*.ts'],
101
- 'cucumber.features': ['tests/features/**/*.feature']
102
- },
103
- null,
104
- 2
105
- );
106
- fs.writeFileSync(vscodeSettingsPath, vscodeSettings, 'utf8');
107
- console.log('✅ .vscode/settings.json created for Cucumber extension.\n');
108
- } else {
109
- console.log('⚠️ .vscode/settings.json already exists. Skipping.\n');
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
- // Install Cucumber VS Code extension
113
- try {
114
- execSync('code --install-extension cucumberopen.cucumber-official', { stdio: 'inherit' });
115
- console.log('✅ Cucumber VS Code extension installed.\n');
116
- } catch {
117
- console.log(
118
- '⚠️ Could not install VS Code extension automatically. Install manually: cucumberopen.cucumber-official\n'
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 included in the `tests/` folder.\n For more information about Cucumber, visit: https://cucumber.io/\n\n - To start the server, run:\n `plum start` \n\n - If you are developing locally, run:\n `plum dev <@tag/blank if you want to run all tests>`'
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 <init|start|dev|create-step>');
251
- console.log('--------------------------------------\n');
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
  }