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
@@ -3,6 +3,8 @@
3
3
  "version": "1.0.0",
4
4
  "main": "index.js",
5
5
  "scripts": {
6
+ "init": "node services/envService.js && node config/scripts/create-settings.js",
7
+ "create-step": "node config/scripts/create-step.mjs",
6
8
  "create-env": "node services/envService.js",
7
9
  "test": "node config/scripts/run-tests.js"
8
10
  },
@@ -13,11 +15,11 @@
13
15
  "@playwright/test": "^1.50.1",
14
16
  "@types/node": "^22.17.0",
15
17
  "cross-env": "^7.0.3",
16
- "cucumber-html-reporter": "^7.2.0",
17
18
  "ts-node": "^10.9.2",
18
19
  "typescript": "^5.9.2"
19
20
  },
20
21
  "dependencies": {
22
+ "@clack/prompts": "^1.5.1",
21
23
  "@cucumber/cucumber": "^11.2.0",
22
24
  "chai": "^4.3.6",
23
25
  "chai-soft-assert": "^0.0.5",
@@ -25,6 +27,7 @@
25
27
  "dotenv": "^16.4.7",
26
28
  "express": "^4.21.2",
27
29
  "node-cron": "^3.0.3",
30
+ "picocolors": "^1.1.1",
28
31
  "playwright": "^1.50.1",
29
32
  "socket.io": "^4.8.1"
30
33
  }
@@ -19,24 +19,20 @@ const express = require('express');
19
19
  const router = express.Router();
20
20
  const reportService = require('../services/reportService');
21
21
 
22
- /* -----------------------------------------------------
23
- * Get Reports
24
- * Description:
25
- * Get all reports from reports/
26
- * ------------------------------------------------------ */
27
22
  router.get('/', (req, res) => {
28
23
  const reports = reportService.getAllReports();
29
24
  res.json({ reports });
30
25
  });
31
26
 
32
- /* -----------------------------------------------------
33
- * Get Latest Report
34
- * Description:
35
- * Get latest report
36
- * ------------------------------------------------------ */
37
27
  router.get('/latest', (req, res) => {
38
28
  const latestReport = reportService.getLatestReport();
39
29
  res.json({ latestReport });
40
30
  });
41
31
 
32
+ router.get('/:fileName/detail', (req, res) => {
33
+ const detail = reportService.getReportDetail(req.params.fileName);
34
+ if (!detail) return res.status(404).json({ error: 'Report not found' });
35
+ res.json(detail);
36
+ });
37
+
42
38
  module.exports = router;
@@ -17,27 +17,21 @@
17
17
 
18
18
  const fs = require('fs');
19
19
  const path = require('path');
20
+ const pc = require('picocolors');
20
21
 
21
- /* -----------------------------------------------------
22
- * .env Generator
23
- * Description:
24
- * If .env file doesn't exist, this will create
25
- * a .env file in root with sauce demo's base URL
26
- * ------------------------------------------------------ */
27
22
  const envPath = path.join(__dirname, '../.env');
28
23
 
29
24
  if (!fs.existsSync(envPath)) {
30
25
  const envContent = 'BASE_URL=https://www.saucedemo.com/v1/\nIS_HEADLESS=false';
31
26
  fs.writeFileSync(envPath, envContent);
32
- console.log('.env file created in the root directory');
27
+ console.log(pc.green('✓') + ' .env created with default values.');
33
28
  } else {
34
29
  let envContent = fs.readFileSync(envPath, 'utf8');
35
-
36
30
  if (!envContent.includes('IS_HEADLESS=')) {
37
31
  envContent += '\nIS_HEADLESS=false';
38
32
  fs.writeFileSync(envPath, envContent);
39
- console.log('IS_HEADLESS=false added to .env');
33
+ console.log(pc.cyan('↳') + ' IS_HEADLESS=false added to .env.');
40
34
  } else {
41
- console.log('.env file already contains IS_HEADLESS setting');
35
+ console.log(pc.yellow('⚠') + ' .env already exists. Skipping.');
42
36
  }
43
37
  }
@@ -22,29 +22,79 @@ const REPORTS_DIR = path.join(__dirname, '../reports');
22
22
 
23
23
  const getAllReports = () => {
24
24
  const files = fs.readdirSync(REPORTS_DIR);
25
-
26
- // Get all files ending with '.html'
27
- const htmlFiles = files.filter((file) => file.endsWith('.html'));
28
-
29
- // Sort files by modification time (latest first)
30
- const sortedFiles = htmlFiles.sort((a, b) => {
31
- const aStats = fs.statSync(path.join(REPORTS_DIR, a));
32
- const bStats = fs.statSync(path.join(REPORTS_DIR, b));
33
- return bStats.mtime - aStats.mtime; // Sort in descending order of modification time
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;
34
32
  });
35
-
36
- return sortedFiles;
37
33
  };
38
34
 
39
35
  const getLatestReport = () => {
40
- const reportFiles = getAllReports()
41
- .map((file) => ({
42
- file,
43
- time: fs.statSync(path.join(REPORTS_DIR, file)).mtime.getTime()
44
- }))
45
- .sort((a, b) => b.time - a.time);
46
-
47
- return reportFiles.length ? reportFiles[0].file : null;
36
+ const files = getAllReports();
37
+ return files.length ? files[0] : null;
38
+ };
39
+
40
+ const getReportDetail = (fileName) => {
41
+ const filePath = path.join(REPORTS_DIR, fileName);
42
+ if (!filePath.startsWith(REPORTS_DIR)) return null; // path traversal guard
43
+ if (!fs.existsSync(filePath)) return null;
44
+
45
+ let raw;
46
+ try {
47
+ raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
48
+ } catch {
49
+ return null;
50
+ }
51
+
52
+ const features = raw.map((feature) => {
53
+ const scenarios = (feature.elements || []).map((scenario) => {
54
+ const visibleSteps = (scenario.steps || []).filter((s) => !s.hidden);
55
+ const hookScreenshots = (scenario.steps || [])
56
+ .filter((s) => s.hidden)
57
+ .flatMap((step) => step.embeddings?.filter((e) => e.mime_type === 'image/png') ?? []);
58
+ const failedStepIndex = visibleSteps.findLastIndex((s) => s.result?.status === 'failed');
59
+
60
+ 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,
66
+ screenshot:
67
+ step.embeddings?.find((e) => e.mime_type === 'image/png')?.data ??
68
+ (index === failedStepIndex ? hookScreenshots[0]?.data : null) ??
69
+ null
70
+ }));
71
+
72
+ const worstStatus = steps.reduce((acc, s) => {
73
+ const rank = { failed: 3, pending: 2, skipped: 1, passed: 0 };
74
+ return (rank[s.status] ?? 0) > (rank[acc] ?? 0) ? s.status : acc;
75
+ }, 'passed');
76
+
77
+ return {
78
+ name: scenario.name,
79
+ keyword: scenario.keyword,
80
+ tags: (scenario.tags ?? []).map((t) => t.name),
81
+ status: worstStatus,
82
+ duration: steps.reduce((s, st) => s + st.duration, 0),
83
+ steps
84
+ };
85
+ });
86
+
87
+ const featureStatus = scenarios.some((s) => s.status === 'failed') ? 'failed' : 'passed';
88
+
89
+ return {
90
+ name: feature.name,
91
+ uri: feature.uri,
92
+ status: featureStatus,
93
+ scenarios
94
+ };
95
+ });
96
+
97
+ return { features };
48
98
  };
49
99
 
50
- module.exports = { getAllReports, getLatestReport };
100
+ module.exports = { getAllReports, getLatestReport, getReportDetail };
@@ -22,45 +22,120 @@ const FEATURES_DIR = path.join(__dirname, '../tests/features');
22
22
 
23
23
  const getTestSuites = () => {
24
24
  const suites = [];
25
-
26
- const files = fs.readdirSync(FEATURES_DIR).filter((file) => file.endsWith('.feature'));
25
+ const files = fs.readdirSync(FEATURES_DIR).filter((f) => f.endsWith('.feature'));
27
26
 
28
27
  files.forEach((file) => {
29
28
  const content = fs.readFileSync(path.join(FEATURES_DIR, file), 'utf8');
29
+ const lines = content.split('\n');
30
30
 
31
31
  let suiteName = '';
32
32
  let suiteTags = [];
33
+ let backgroundSteps = [];
33
34
  const tests = [];
34
35
 
35
- // Suites Tag Extractor
36
- const featureMatch = content.match(/Feature:\s*(.+)/);
37
- if (featureMatch) {
38
- suiteName = featureMatch[1].trim();
39
- }
36
+ let inBackground = false;
37
+ let inExamples = false;
38
+ let pendingTags = [];
39
+ let currentTest = null;
40
40
 
41
- const suiteTagsMatch = content.match(/^(@[^\n]+)/m);
42
- if (suiteTagsMatch) {
43
- suiteTags = suiteTagsMatch[0].split(/\s+/).filter((tag) => tag.trim() !== ''); // Remove empty elements
44
- }
41
+ const finalizeTest = () => {
42
+ if (currentTest) {
43
+ tests.push(currentTest);
44
+ currentTest = null;
45
+ }
46
+ };
45
47
 
46
- // Test Cases Tag Extractor
47
- const scenarioMatches = [...content.matchAll(/(@[^\n]+)\s*Scenario:\s*(.+)/g)];
48
- scenarioMatches.forEach((match) => {
49
- const tags = match[1].split(/\s+/).filter((tag) => tag.trim() !== ''); // Remove empty elements
50
- const scenarioText = match[2].trim();
48
+ for (const raw of lines) {
49
+ const trimmed = raw.trim();
50
+ if (!trimmed || trimmed.startsWith('#')) continue;
51
51
 
52
- if (tags.length === 0) return; // Skip if there are no valid tags
52
+ // Tag line
53
+ if (trimmed.startsWith('@')) {
54
+ finalizeTest();
55
+ inBackground = false;
56
+ inExamples = false;
57
+ const tags = trimmed.split(/\s+/).filter(Boolean);
58
+ if (!suiteName) {
59
+ suiteTags = [...suiteTags, ...tags];
60
+ } else {
61
+ pendingTags = [...pendingTags, ...tags];
62
+ }
63
+ continue;
64
+ }
53
65
 
54
- const testId = tags.shift(); // Extract the first tag as testId
66
+ // Feature
67
+ if (trimmed.startsWith('Feature:')) {
68
+ suiteName = trimmed.replace('Feature:', '').trim();
69
+ continue;
70
+ }
55
71
 
56
- tests.push({
57
- id: tags.length > 0 ? [testId, ...tags] : testId, // Ensure testId is correctly structured
58
- testCase: scenarioText
59
- });
60
- });
72
+ // Background
73
+ if (trimmed.startsWith('Background:')) {
74
+ inBackground = true;
75
+ inExamples = false;
76
+ continue;
77
+ }
78
+
79
+ // Scenario or Scenario Outline
80
+ const scenarioMatch = trimmed.match(/^Scenario(?:\s+Outline)?:\s*(.+)/i);
81
+ if (scenarioMatch) {
82
+ finalizeTest();
83
+ inBackground = false;
84
+ inExamples = false;
85
+
86
+ const isOutline = /^Scenario\s+Outline:/i.test(trimmed);
87
+ const tags = pendingTags.splice(0);
88
+ const [testId, ...extraTags] = tags;
89
+
90
+ currentTest = {
91
+ id: extraTags.length > 0 ? [testId, ...extraTags] : testId,
92
+ testCase: scenarioMatch[1].trim(),
93
+ type: isOutline ? 'outline' : 'scenario',
94
+ steps: [...backgroundSteps],
95
+ ...(isOutline ? { examples: null } : {})
96
+ };
97
+ continue;
98
+ }
99
+
100
+ // Examples header (Scenario Outline)
101
+ if (trimmed.startsWith('Examples:')) {
102
+ inExamples = true;
103
+ if (currentTest) currentTest.examples = { headers: [], rows: [] };
104
+ continue;
105
+ }
106
+
107
+ // Step lines
108
+ if (/^(Given|When|Then|And|But)\s/i.test(trimmed)) {
109
+ if (inBackground) {
110
+ backgroundSteps.push(trimmed);
111
+ } else if (currentTest && !inExamples) {
112
+ currentTest.steps.push(trimmed);
113
+ }
114
+ continue;
115
+ }
116
+
117
+ // Examples table rows
118
+ if (inExamples && trimmed.startsWith('|') && currentTest?.examples) {
119
+ const cells = trimmed
120
+ .split('|')
121
+ .filter(Boolean)
122
+ .map((c) => c.trim());
123
+ if (currentTest.examples.headers.length === 0) {
124
+ currentTest.examples.headers = cells;
125
+ } else {
126
+ currentTest.examples.rows.push(cells);
127
+ }
128
+ }
129
+ }
130
+
131
+ finalizeTest();
61
132
 
62
133
  if (suiteName && tests.length) {
63
- suites.push({ suiteName, suiteId: suiteTags.length > 1 ? suiteTags : suiteTags[0], tests });
134
+ suites.push({
135
+ suiteName,
136
+ suiteId: suiteTags.length > 1 ? suiteTags : suiteTags[0],
137
+ tests
138
+ });
64
139
  }
65
140
  });
66
141
 
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "compilerOptions": {
3
3
  "target": "ES2020",
4
- "module": "CommonJS",
5
- "moduleResolution": "node",
4
+ "module": "Node16",
5
+ "moduleResolution": "node16",
6
6
  "esModuleInterop": true,
7
7
  "strict": true,
8
8
  "skipLibCheck": true,
@@ -20,12 +20,18 @@ const { spawn } = require('child_process');
20
20
  const socketHandler = (io) => {
21
21
  io.on('connection', (socket) => {
22
22
  console.log('WebSocket connection established');
23
- socket.on('run-test', (testID) => {
23
+ socket.on('run-test', (testID, workers) => {
24
24
  const tag = testID ? `${testID}` : '';
25
+ const runnerCount = Number(workers) > 1 ? Number(workers) : 1;
26
+ const env = {
27
+ ...process.env,
28
+ TAG: tag,
29
+ TRIGGER: 'manual-trigger',
30
+ REPORT_RUNNERS: String(runnerCount)
31
+ };
32
+ if (workers && workers > 1) env.PARALLEL = String(workers);
25
33
 
26
- const testProcess = spawn('npm', ['run', 'test'], {
27
- env: { ...process.env, TAG: tag, TRIGGER: 'manual-trigger' }
28
- });
34
+ const testProcess = spawn('npm', ['run', 'test'], { env });
29
35
 
30
36
  testProcess.stdout.on('data', (data) => {
31
37
  socket.emit('log', data.toString());
@@ -36,8 +42,8 @@ const socketHandler = (io) => {
36
42
  });
37
43
 
38
44
  testProcess.on('close', (code) => {
39
- socket.emit('log', `Test finished with code ${code}`);
40
- socket.emit('done');
45
+ socket.emit('log', `\nTest finished with code ${code}`);
46
+ socket.emit('done', code);
41
47
  });
42
48
  });
43
49
  });
package/bin/plum.js CHANGED
@@ -91,6 +91,34 @@ switch (command) {
91
91
  // Create .env file with default values
92
92
  createEnvFile();
93
93
 
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');
110
+ }
111
+
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
+ );
120
+ }
121
+
94
122
  // Initialize project
95
123
  console.log('--------------------------------------\n');
96
124
  console.log('🚀 Initializing Plum...');
@@ -152,7 +180,10 @@ switch (command) {
152
180
  // Copy .env file from root to backend
153
181
  copyEnvFile();
154
182
 
155
- const tagArg = process.argv[3]; // This is your tag, like @test-1
183
+ const devArgs = process.argv.slice(3);
184
+ const parallelIdx = devArgs.indexOf('--parallel');
185
+ const parallelArg = parallelIdx !== -1 ? devArgs[parallelIdx + 1] : null;
186
+ const tagArg = devArgs.find((a) => a.startsWith('@')) ?? null;
156
187
  const userTestsPath = path.resolve(process.cwd(), 'tests');
157
188
  const backendTestsPath = path.join(plumRoot, 'backend', 'tests');
158
189
 
@@ -184,6 +215,7 @@ switch (command) {
184
215
  console.log('--------------------------------------\n');
185
216
  console.log('Running `npm run test` with:');
186
217
  console.log('TAG =', tagArg ?? '');
218
+ console.log('PARALLEL =', parallelArg ?? 'off');
187
219
  console.log('TRIGGER =', 'command-line-trigger');
188
220
 
189
221
  execSync('npm run test', {
@@ -192,15 +224,29 @@ switch (command) {
192
224
  env: {
193
225
  ...process.env,
194
226
  TAG: tagArg ?? '',
195
- TRIGGER: 'command-line-trigger'
227
+ TRIGGER: 'command-line-trigger',
228
+ ...(parallelArg ? { PARALLEL: parallelArg } : {})
196
229
  }
197
230
  });
198
231
  console.log('--------------------------------------\n');
199
232
  break;
200
233
  }
201
234
 
235
+ case 'create-step': {
236
+ const createStepScript = path.join(plumRoot, 'backend', 'config', 'scripts', 'create-step.mjs');
237
+ execSync(`node ${createStepScript}`, {
238
+ cwd: process.cwd(),
239
+ stdio: 'inherit',
240
+ env: {
241
+ ...process.env,
242
+ TESTS_ROOT: userTestsPath
243
+ }
244
+ });
245
+ break;
246
+ }
247
+
202
248
  default:
203
249
  console.log('--------------------------------------\n');
204
- console.log('Usage: plum <init|start|dev>');
250
+ console.log('Usage: plum <init|start|dev|create-step>');
205
251
  console.log('--------------------------------------\n');
206
252
  }