plum-e2e 1.0.10 → 1.1.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 (51) hide show
  1. package/.claude/settings.local.json +16 -1
  2. package/.vscode/settings.json +10 -0
  3. package/README.md +151 -37
  4. package/backend/_scaffold/features/LoginPage.feature +45 -3
  5. package/backend/_scaffold/pages/HomepagePage.ts +7 -0
  6. package/backend/_scaffold/pages/LoginPage.ts +37 -13
  7. package/backend/_scaffold/step_definitions/HomepageSteps.ts +6 -0
  8. package/backend/_scaffold/step_definitions/LoginSteps.ts +30 -4
  9. package/backend/_scaffold/utils/browser.ts +33 -0
  10. package/backend/_scaffold/utils/hooks.ts +8 -29
  11. package/backend/_scaffold/utils/utils.ts +3 -9
  12. package/backend/config/scripts/create-settings.js +7 -14
  13. package/backend/config/scripts/create-step.mjs +268 -0
  14. package/backend/config/scripts/generate-report.js +31 -75
  15. package/backend/config/scripts/run-tests.js +19 -4
  16. package/backend/package-lock.json +56 -641
  17. package/backend/package.json +4 -1
  18. package/backend/routes/reports.routes.js +6 -10
  19. package/backend/services/envService.js +4 -10
  20. package/backend/services/reportService.js +70 -20
  21. package/backend/services/testService.js +99 -24
  22. package/backend/tsconfig.json +2 -2
  23. package/backend/websockets/socketHandler.js +12 -6
  24. package/bin/plum.js +49 -3
  25. package/frontend/package-lock.json +436 -135
  26. package/frontend/package.json +1 -1
  27. package/frontend/src/app.css +241 -6
  28. package/frontend/src/app.html +14 -1
  29. package/frontend/src/lib/api/reports.js +68 -0
  30. package/frontend/src/lib/api/schedules.js +64 -0
  31. package/frontend/src/lib/api/tests.js +41 -0
  32. package/frontend/src/lib/components/layout/Nav.svelte +304 -0
  33. package/frontend/src/lib/components/layout/PageShell.svelte +28 -0
  34. package/frontend/src/lib/components/layout/RunnerPanel.svelte +378 -0
  35. package/frontend/src/lib/components/ui/Badge.svelte +63 -0
  36. package/frontend/src/lib/components/ui/Button.svelte +117 -0
  37. package/frontend/src/lib/components/ui/Modal.svelte +140 -0
  38. package/frontend/src/lib/components/ui/Pagination.svelte +100 -0
  39. package/frontend/src/lib/components/ui/Terminal.svelte +100 -0
  40. package/frontend/src/lib/stores/runner.js +55 -0
  41. package/frontend/src/lib/stores/theme.js +47 -0
  42. package/frontend/src/routes/+layout.svelte +7 -12
  43. package/frontend/src/routes/+page.svelte +690 -142
  44. package/frontend/src/routes/reports/+page.svelte +395 -125
  45. package/frontend/src/routes/reports/[slug]/+page.svelte +749 -0
  46. package/frontend/src/routes/scheduled-tests/+page.svelte +267 -303
  47. package/frontend/svelte.config.js +1 -4
  48. package/frontend/tailwind.config.js +2 -23
  49. package/package.json +2 -2
  50. package/backend/_scaffold/utils/world.ts +0 -25
  51. package/frontend/src/routes/components/Navigation.svelte +0 -53
@@ -0,0 +1,268 @@
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
+ import * as clack from '@clack/prompts';
18
+ import pc from 'picocolors';
19
+ import fs from 'fs';
20
+ import path from 'path';
21
+
22
+ const testsRoot = process.env.TESTS_ROOT || path.join(process.cwd(), 'tests');
23
+ const stepDefsPath = path.join(testsRoot, 'step_definitions');
24
+ const pagesPath = path.join(testsRoot, 'pages');
25
+
26
+ /* ------------------------------------------------------------------ */
27
+ /* Helpers */
28
+ /* ------------------------------------------------------------------ */
29
+
30
+ function capitalize(str) {
31
+ return str.charAt(0).toUpperCase() + str.slice(1);
32
+ }
33
+
34
+ function toMethodName(stepText) {
35
+ return stepText
36
+ .toLowerCase()
37
+ .replace(/[^a-z0-9\s]/g, '')
38
+ .trim()
39
+ .split(/\s+/)
40
+ .map((word, i) => (i === 0 ? word : capitalize(word)))
41
+ .join('');
42
+ }
43
+
44
+ function toClassName(name) {
45
+ return capitalize(name.trim()) + 'Page';
46
+ }
47
+
48
+ function toStepFileName(name) {
49
+ return capitalize(name.trim()) + 'Steps.ts';
50
+ }
51
+
52
+ function toPageFileName(name) {
53
+ return toClassName(name) + '.ts';
54
+ }
55
+
56
+ /* ------------------------------------------------------------------ */
57
+ /* File generators */
58
+ /* ------------------------------------------------------------------ */
59
+
60
+ function generatePageFile(pageClassName, methodName) {
61
+ return `import { page } from '../utils/browser';
62
+
63
+ export class ${pageClassName} {
64
+ \tstatic async ${methodName}() {
65
+ \t\t// TODO: implement
66
+ \t}
67
+ }
68
+ `;
69
+ }
70
+
71
+ function generateStepFile(stepType, stepText, methodName, pageClassName, pageBaseName) {
72
+ return `import { ${stepType} } from '@cucumber/cucumber';
73
+ import { ${pageClassName} } from '../pages/${pageBaseName}';
74
+
75
+ ${stepType}('${stepText}', async () => {
76
+ \tawait ${pageClassName}.${methodName}();
77
+ });
78
+ `;
79
+ }
80
+
81
+ function appendMethodToPage(filePath, methodName) {
82
+ let content = fs.readFileSync(filePath, 'utf8');
83
+ const method = `\n\tstatic async ${methodName}() {\n\t\t// TODO: implement\n\t}\n`;
84
+ const lastBrace = content.lastIndexOf('}');
85
+ content = content.slice(0, lastBrace) + method + content.slice(lastBrace);
86
+ fs.writeFileSync(filePath, content, 'utf8');
87
+ }
88
+
89
+ function appendStepToFile(filePath, stepType, stepText, methodName, pageClassName, pageBaseName) {
90
+ let content = fs.readFileSync(filePath, 'utf8');
91
+
92
+ // Update @cucumber/cucumber import to include the new step type if missing
93
+ const cucumberImportMatch = content.match(/import\s*\{([^}]+)\}\s*from\s*'@cucumber\/cucumber';/);
94
+ if (cucumberImportMatch) {
95
+ const existing = cucumberImportMatch[1].split(',').map((s) => s.trim());
96
+ if (!existing.includes(stepType)) {
97
+ const updated = [...existing, stepType].join(', ');
98
+ content = content.replace(
99
+ cucumberImportMatch[0],
100
+ `import { ${updated} } from '@cucumber/cucumber';`
101
+ );
102
+ }
103
+ }
104
+
105
+ // Add page import if missing
106
+ const pageImportLine = `import { ${pageClassName} } from '../pages/${pageBaseName}';`;
107
+ if (!content.includes(pageImportLine)) {
108
+ const lastImportIdx = content.lastIndexOf('\nimport ');
109
+ const insertAt = content.indexOf('\n', lastImportIdx + 1) + 1;
110
+ content = content.slice(0, insertAt) + pageImportLine + '\n' + content.slice(insertAt);
111
+ }
112
+
113
+ const stepBlock = `\n${stepType}('${stepText}', async () => {\n\tawait ${pageClassName}.${methodName}();\n});\n`;
114
+ content = content.trimEnd() + '\n' + stepBlock;
115
+ fs.writeFileSync(filePath, content, 'utf8');
116
+ }
117
+
118
+ /* ------------------------------------------------------------------ */
119
+ /* Main */
120
+ /* ------------------------------------------------------------------ */
121
+
122
+ async function main() {
123
+ clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Create Step ')));
124
+
125
+ // 1. Step type
126
+ const stepTypeChoice = await clack.select({
127
+ message: 'Step type',
128
+ options: [
129
+ { value: 'Given', label: 'Given', hint: 'initial context' },
130
+ { value: 'When', label: 'When', hint: 'action' },
131
+ { value: 'And', label: 'And', hint: 'continuation (uses When)' },
132
+ { value: 'Then', label: 'Then', hint: 'expected outcome' }
133
+ ]
134
+ });
135
+ if (clack.isCancel(stepTypeChoice)) {
136
+ clack.cancel('Cancelled.');
137
+ process.exit(0);
138
+ }
139
+ const stepType = stepTypeChoice === 'And' ? 'When' : stepTypeChoice;
140
+
141
+ // 2. Step text
142
+ const stepText = await clack.text({
143
+ message: 'Step text',
144
+ placeholder: 'I am on the login page',
145
+ validate: (v) => (!v.trim() ? 'Step text is required' : undefined)
146
+ });
147
+ if (clack.isCancel(stepText)) {
148
+ clack.cancel('Cancelled.');
149
+ process.exit(0);
150
+ }
151
+
152
+ // 3. Step definition file
153
+ const existingFiles = fs.existsSync(stepDefsPath)
154
+ ? fs.readdirSync(stepDefsPath).filter((f) => f.endsWith('.ts'))
155
+ : [];
156
+
157
+ const stepFile = await clack.select({
158
+ message: 'Add to step definition file',
159
+ options: [
160
+ { value: '__new__', label: pc.green('+ New Step Definition') },
161
+ ...existingFiles.map((f) => ({ value: f, label: f }))
162
+ ]
163
+ });
164
+ if (clack.isCancel(stepFile)) {
165
+ clack.cancel('Cancelled.');
166
+ process.exit(0);
167
+ }
168
+
169
+ let stepFileName;
170
+ if (stepFile === '__new__') {
171
+ const newName = await clack.text({
172
+ message: 'Step definition name',
173
+ placeholder: 'home',
174
+ hint: 'e.g. "home" → HomeSteps.ts',
175
+ validate: (v) => (!v.trim() ? 'Name is required' : undefined)
176
+ });
177
+ if (clack.isCancel(newName)) {
178
+ clack.cancel('Cancelled.');
179
+ process.exit(0);
180
+ }
181
+ stepFileName = toStepFileName(newName);
182
+ } else {
183
+ stepFileName = stepFile;
184
+ }
185
+
186
+ // 4. Page selection
187
+ const existingPages = fs.existsSync(pagesPath)
188
+ ? fs.readdirSync(pagesPath).filter((f) => f.endsWith('.ts'))
189
+ : [];
190
+
191
+ const pageChoice = await clack.select({
192
+ message: 'Which page does this step use?',
193
+ options: [
194
+ { value: '__new__', label: pc.green('+ New Page') },
195
+ ...existingPages.map((f) => ({ value: f, label: f }))
196
+ ]
197
+ });
198
+ if (clack.isCancel(pageChoice)) {
199
+ clack.cancel('Cancelled.');
200
+ process.exit(0);
201
+ }
202
+
203
+ let pageFileName;
204
+ if (pageChoice === '__new__') {
205
+ const newPageName = await clack.text({
206
+ message: 'Page name',
207
+ placeholder: 'home',
208
+ hint: '"Page.ts" will be appended — e.g. "home" → HomePage.ts',
209
+ validate: (v) => (!v.trim() ? 'Page name is required' : undefined)
210
+ });
211
+ if (clack.isCancel(newPageName)) {
212
+ clack.cancel('Cancelled.');
213
+ process.exit(0);
214
+ }
215
+ pageFileName = toPageFileName(newPageName);
216
+ } else {
217
+ pageFileName = pageChoice;
218
+ }
219
+
220
+ const pageClassName = pageFileName.replace('.ts', '');
221
+ const pageBaseName = pageFileName.replace('.ts', '');
222
+ const methodName = toMethodName(stepText);
223
+
224
+ const s = clack.spinner();
225
+ s.start('Generating files...');
226
+
227
+ // Handle page file
228
+ const pageFilePath = path.join(pagesPath, pageFileName);
229
+ fs.mkdirSync(pagesPath, { recursive: true });
230
+ if (!fs.existsSync(pageFilePath)) {
231
+ fs.writeFileSync(pageFilePath, generatePageFile(pageClassName, methodName), 'utf8');
232
+ s.stop(pc.green(`✓ Created ${pageFileName}`));
233
+ } else {
234
+ appendMethodToPage(pageFilePath, methodName);
235
+ s.stop(pc.cyan(`↳ Added ${methodName}() to ${pageFileName}`));
236
+ }
237
+
238
+ // Handle step definition file
239
+ const stepFilePath = path.join(stepDefsPath, stepFileName);
240
+ fs.mkdirSync(stepDefsPath, { recursive: true });
241
+ if (!fs.existsSync(stepFilePath)) {
242
+ fs.writeFileSync(
243
+ stepFilePath,
244
+ generateStepFile(stepType, stepText, methodName, pageClassName, pageBaseName),
245
+ 'utf8'
246
+ );
247
+ clack.log.success(pc.green(`Created ${stepFileName}`));
248
+ } else {
249
+ appendStepToFile(stepFilePath, stepType, stepText, methodName, pageClassName, pageBaseName);
250
+ clack.log.info(pc.cyan(`Added step to ${stepFileName}`));
251
+ }
252
+
253
+ clack.note(
254
+ [
255
+ `${pc.dim('Step:')} ${pc.white(`${stepType}('${stepText}')`)}`,
256
+ `${pc.dim('Page:')} ${pc.white(`${pageClassName}.${methodName}()`)}`,
257
+ `${pc.dim('Files:')} ${pc.white(stepFileName)} ${pc.dim('+')} ${pc.white(pageFileName)}`
258
+ ].join('\n'),
259
+ 'Summary'
260
+ );
261
+
262
+ clack.outro(pc.magenta('Done! Remember to implement the page method.'));
263
+ }
264
+
265
+ main().catch((err) => {
266
+ clack.log.error(err.message);
267
+ process.exit(1);
268
+ });
@@ -15,50 +15,34 @@
15
15
  * along with Plum. If not, see https://www.gnu.org/licenses/.
16
16
  */
17
17
 
18
- const reporter = require('cucumber-html-reporter');
19
18
  const fs = require('fs');
20
19
  const path = require('path');
21
20
  const { reportsHistory } = require('../settings.json');
22
21
 
23
- /* -----------------------------------------------------
24
- * Screenshot and Report Management
25
- *
26
- * Description:
27
- * To avoid reports and screenshots extract
28
- * status, tags, run name, and date.
29
- * ------------------------------------------------------ */
30
-
31
22
  const reportsDir = 'reports';
32
23
  const screenshotsDir = path.join(reportsDir, 'screenshots');
33
24
  const maxReports = reportsHistory;
34
25
 
35
- // Ensure the reports and screenshots directories exist
36
- if (!fs.existsSync(reportsDir)) {
37
- fs.mkdirSync(reportsDir, { recursive: true });
38
- }
39
-
40
- if (!fs.existsSync(screenshotsDir)) {
41
- fs.mkdirSync(screenshotsDir, { recursive: true });
42
- }
26
+ if (!fs.existsSync(reportsDir)) fs.mkdirSync(reportsDir, { recursive: true });
27
+ if (!fs.existsSync(screenshotsDir)) fs.mkdirSync(screenshotsDir, { recursive: true });
43
28
 
44
- // Remove the oldest report depending on maxReport
29
+ // Clean up old timestamped report JSON files
45
30
  const existingReports = fs
46
31
  .readdirSync(reportsDir)
47
- .filter((file) => file.startsWith('PASS_') || file.startsWith('FAIL_'))
32
+ .filter((f) => f.endsWith('.json') && (f.startsWith('PASS_') || f.startsWith('FAIL_')))
48
33
  .sort(
49
34
  (a, b) =>
50
35
  fs.statSync(path.join(reportsDir, b)).mtime - fs.statSync(path.join(reportsDir, a)).mtime
51
36
  );
52
37
 
53
38
  while (existingReports.length >= maxReports) {
54
- const oldestReport = existingReports.pop();
55
- fs.unlinkSync(path.join(reportsDir, oldestReport));
39
+ fs.unlinkSync(path.join(reportsDir, existingReports.pop()));
56
40
  }
57
41
 
58
- // Remove the oldest screenshot depending on maxReport
42
+ // Clean up old screenshots
59
43
  const existingScreenshots = fs
60
44
  .readdirSync(screenshotsDir)
61
- .filter((file) => file.endsWith('.png') || file.endsWith('.jpg'))
45
+ .filter((f) => f.endsWith('.png') || f.endsWith('.jpg'))
62
46
  .sort(
63
47
  (a, b) =>
64
48
  fs.statSync(path.join(screenshotsDir, b)).mtime -
@@ -66,70 +50,42 @@ const existingScreenshots = fs
66
50
  );
67
51
 
68
52
  while (existingScreenshots.length >= maxReports) {
69
- const oldestScreenshot = existingScreenshots.pop();
70
- fs.unlinkSync(path.join(screenshotsDir, oldestScreenshot));
53
+ fs.unlinkSync(path.join(screenshotsDir, existingScreenshots.pop()));
71
54
  }
72
55
 
73
- /* -----------------------------------------------------
74
- * Generate the report name
75
- *
76
- * Description:
77
- * This is where the reports page will
78
- * extract status, tags, run name, and date.
79
- * ------------------------------------------------------ */
80
-
56
+ // Determine PASS/FAIL from cucumber JSON output
81
57
  const jsonReportFile = path.join(reportsDir, 'cucumber_report.json');
82
58
  let status = 'PASS';
83
59
 
84
60
  if (fs.existsSync(jsonReportFile)) {
85
- const reportData = fs.readFileSync(jsonReportFile, 'utf8');
86
-
87
- // Ensure the JSON is not empty
88
- if (reportData) {
89
- const parsedData = JSON.parse(reportData);
90
-
61
+ try {
62
+ const parsedData = JSON.parse(fs.readFileSync(jsonReportFile, 'utf8'));
91
63
  const hasFailures = parsedData.some((feature) =>
92
- feature.elements.some((scenario) =>
93
- scenario.steps.some((step) => step.result.status === 'failed')
64
+ feature.elements?.some((scenario) =>
65
+ scenario.steps?.some((step) => step.result?.status === 'failed')
94
66
  )
95
67
  );
96
-
97
- if (hasFailures) {
98
- status = 'FAIL';
99
- }
100
- } else {
101
- console.log('Report file is empty or malformed');
68
+ if (hasFailures) status = 'FAIL';
69
+ } catch (e) {
70
+ console.error('Could not parse cucumber_report.json:', e.message);
102
71
  }
72
+ } else {
73
+ console.log('No cucumber_report.json found.');
103
74
  }
104
75
 
105
- let tag = process.env.TAG;
106
-
107
- if (!tag) {
108
- tag = '(@all-tests)';
109
- }
110
-
111
- if (tag && !tag.startsWith('(') && !tag.endsWith(')')) {
112
- tag = `(${tag})`;
113
- }
76
+ // Build filename with same convention as before, now .json
77
+ let tag = process.env.TAG || '(@all-tests)';
78
+ if (tag && !tag.startsWith('(')) tag = `(${tag})`;
114
79
 
115
80
  const timestamp = new Date().toISOString().replace(/[-:.]/g, '_');
116
- const reportFileName = `${status}_cucumber_report_${process.env.TRIGGER}_${tag}_${timestamp}.html`;
81
+ const runners = Number.parseInt(process.env.REPORT_RUNNERS || process.env.PARALLEL || '1', 10);
82
+ const runnerCount = Number.isFinite(runners) && runners > 0 ? runners : 1;
83
+ const reportFileName = `${status}_cucumber_report_${process.env.TRIGGER}_${tag}_runners_${runnerCount}_${timestamp}.json`;
117
84
 
118
- const options = {
119
- theme: 'bootstrap',
120
- jsonFile: jsonReportFile,
121
- output: path.join(reportsDir, reportFileName),
122
- reportSuiteAsScenarios: true,
123
- launchReport: true, // Automatically opens the report in a browser
124
- metadata: {
125
- 'App Name': 'Plum',
126
- 'Test Environment': 'Local',
127
- Browser: 'Chromium',
128
- Platform: 'Ubuntu',
129
- Parallel: 'Scenarios',
130
- Executed: 'Local'
131
- }
132
- };
133
-
134
- reporter.generate(options);
135
- console.log(`Generated report: ${reportFileName}`);
85
+ // Save a timestamped snapshot of the cucumber JSON
86
+ if (fs.existsSync(jsonReportFile)) {
87
+ fs.copyFileSync(jsonReportFile, path.join(reportsDir, reportFileName));
88
+ console.log(`Report saved: ${reportFileName}`);
89
+ } else {
90
+ console.log('Skipping report save no cucumber_report.json.');
91
+ }
@@ -16,7 +16,13 @@
16
16
  */
17
17
 
18
18
  const { execSync } = require('child_process');
19
- const tag = process.env.TAG;
19
+ const pc = require('picocolors');
20
+
21
+ const parallelIdx = process.argv.indexOf('--parallel');
22
+ const parallel =
23
+ process.env.PARALLEL || (parallelIdx !== -1 ? process.argv[parallelIdx + 1] : null);
24
+ const runners = parallel || process.env.REPORT_RUNNERS || '1';
25
+ const tag = process.env.TAG || process.argv.slice(2).find((a) => a.startsWith('@'));
20
26
 
21
27
  try {
22
28
  const baseCommand = [
@@ -28,6 +34,8 @@ try {
28
34
  '--require-module',
29
35
  'ts-node/register',
30
36
  '--require',
37
+ 'tests/utils/hooks.ts',
38
+ '--require',
31
39
  'tests/step_definitions/**/*.ts',
32
40
  '--format',
33
41
  'json:reports/cucumber_report.json'
@@ -37,14 +45,21 @@ try {
37
45
  baseCommand.push('--tags', `"${tag}"`);
38
46
  }
39
47
 
48
+ if (parallel) {
49
+ baseCommand.push('--parallel', parallel);
50
+ }
51
+
40
52
  const cucumberCommand = baseCommand.join(' ');
41
53
  execSync(cucumberCommand, { stdio: 'inherit' });
42
54
  } catch (error) {
43
- console.error('Tests failed:', error.message);
55
+ console.error(pc.red('✗') + ' Tests failed: ' + error.message);
44
56
  } finally {
45
57
  try {
46
- execSync('node config/scripts/generate-report.js', { stdio: 'inherit' });
58
+ execSync('node config/scripts/generate-report.js', {
59
+ stdio: 'inherit',
60
+ env: { ...process.env, REPORT_RUNNERS: runners }
61
+ });
47
62
  } catch (error) {
48
- console.error('Error running report generation:', error.message);
63
+ console.error(pc.red('✗') + ' Report generation failed: ' + error.message);
49
64
  }
50
65
  }