plum-e2e 2.8.6 → 2.9.1

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 (62) hide show
  1. package/README.md +20 -19
  2. package/backend/_scaffold/utils/browser.ts +7 -63
  3. package/backend/_scaffold/utils/hooks.ts +5 -20
  4. package/backend/app.js +0 -5
  5. package/backend/config/scripts/generate-report.js +2 -2
  6. package/backend/config/scripts/run-tests.js +5 -1
  7. package/backend/constants/socketEvents.js +7 -4
  8. package/backend/lib/plumTestRuntime.js +262 -0
  9. package/backend/lib/reportFilename.js +1 -2
  10. package/backend/lib/{screenshotPoller.js → rrwebPoller.js} +7 -4
  11. package/backend/lib/serverBootstrap.js +14 -0
  12. package/backend/logs/runner-cmtbz5b1l0000mr0110b27w5k.log +8 -0
  13. package/backend/mcp/server.js +3 -47
  14. package/backend/package-lock.json +199 -1
  15. package/backend/package.json +3 -1
  16. package/backend/prisma/migrations/20260828120000_add_recording_and_split_runner_worker_count/migration.sql +39 -0
  17. package/backend/prisma/migrations/20260828140000_add_recording_started_ended_at/migration.sql +5 -0
  18. package/backend/prisma/migrations/20260828150000_strip_screenshot_refs_from_reports/migration.sql +34 -0
  19. package/backend/prisma/migrations/20260828160000_add_backup_include_reports/migration.sql +4 -0
  20. package/backend/prisma/schema.prisma +97 -70
  21. package/backend/routes/backup.routes.js +47 -1
  22. package/backend/routes/reports.routes.js +23 -0
  23. package/backend/server.js +1 -1
  24. package/backend/services/backupCronService.js +1 -1
  25. package/backend/services/backupService.js +134 -44
  26. package/backend/services/cronService.js +23 -15
  27. package/backend/services/nodeExecutionService.js +41 -15
  28. package/backend/services/nodeStreamRegistry.js +24 -0
  29. package/backend/services/reportService.js +167 -83
  30. package/backend/services/runnerService.js +19 -7
  31. package/backend/services/settingsService.js +6 -3
  32. package/backend/services/triggerService.js +20 -13
  33. package/backend/websockets/nodeSocketHandler.js +40 -0
  34. package/backend/websockets/socketHandler.js +9 -7
  35. package/bin/plum.js +58 -1
  36. package/frontend/.svelte-kit/ambient.d.ts +28 -28
  37. package/frontend/.svelte-kit/generated/server/internal.js +1 -1
  38. package/frontend/package-lock.json +121 -32
  39. package/frontend/package.json +1 -0
  40. package/frontend/src/lib/api/reports.js +13 -4
  41. package/frontend/src/lib/api/settings.js +16 -1
  42. package/frontend/src/lib/components/layout/RunnerPanel.svelte +9 -24
  43. package/frontend/src/lib/components/reports/ElementInspector.svelte +141 -0
  44. package/frontend/src/lib/components/reports/LiveReplayer.svelte +110 -0
  45. package/frontend/src/lib/components/reports/MultiTabTimeline.svelte +115 -0
  46. package/frontend/src/lib/components/reports/RecordingPlayer.svelte +786 -0
  47. package/frontend/src/lib/components/reports/StepsRail.svelte +109 -0
  48. package/frontend/src/lib/components/ui/CodeViewer.svelte +61 -0
  49. package/frontend/src/lib/constants.js +0 -1
  50. package/frontend/src/lib/copy/reports.js +18 -8
  51. package/frontend/src/lib/copy/settings.js +25 -4
  52. package/frontend/src/lib/socketEvents.js +7 -4
  53. package/frontend/src/lib/stores/runner.js +26 -3
  54. package/frontend/src/lib/styles/tokens.css +7 -0
  55. package/frontend/src/lib/utils/format.js +108 -2
  56. package/frontend/src/lib/utils/inspectElement.js +34 -0
  57. package/frontend/src/routes/reports/+page.svelte +79 -1
  58. package/frontend/src/routes/reports/[id]/+page.svelte +304 -495
  59. package/frontend/src/routes/reports/live/+page.svelte +236 -260
  60. package/frontend/src/routes/settings/+page.svelte +246 -8
  61. package/package.json +1 -1
  62. package/backend/playwright.config.js +0 -85
package/README.md CHANGED
@@ -79,25 +79,26 @@ Full documentation is available at:
79
79
 
80
80
  ## Command Reference
81
81
 
82
- | Command | Description |
83
- | ----------------------------- | ------------------------------------------------------------------ |
84
- | `plum init` | Initialize a new project in the current folder |
85
- | `plum server start` | Start the full UI stack via Docker |
86
- | `plum server restart` | Rebuild Docker images and restart the server without prompts |
87
- | `plum server stop` | Stop the server (data preserved) |
88
- | `plum server reconfig` | Re-enter server settings without starting |
89
- | `plum update` | Update Plum and auto-restart whatever is running (server or node) |
90
- | `plum node start` | Set up connectivity, start a runner node, and open the runner menu |
91
- | `plum node restart` | Stop, refresh dependencies, and restart the runner node |
92
- | `plum node stop` | Stop the runner node started from this folder |
93
- | `plum node reconfig` | Re-enter node settings and re-register |
94
- | `plum run-test` | Run all tests locally without Docker |
95
- | `plum run-test @tag` | Run tests matching a tag |
96
- | `plum run-test --parallel N` | Run tests across N parallel workers |
97
- | `plum run-test --browser <b>` | Run in `chromium` (default) or `firefox` |
98
- | `plum run-test --help` | Show usage for `run-test` |
99
- | `plum create-step` | Interactively scaffold a new step definition |
100
- | `plum manage-runners` | Open the interactive runner management menu |
82
+ | Command | Description |
83
+ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
84
+ | `plum init` | Initialize a new project in the current folder |
85
+ | `plum server start` | Start the full UI stack via Docker |
86
+ | `plum server restart` | Rebuild Docker images and restart the server without prompts |
87
+ | `plum server stop` | Stop the server (data preserved) |
88
+ | `plum server reconfig` | Re-enter server settings without starting |
89
+ | `plum update` | Update Plum, auto-restart whatever is running (server or node), and re-sync `tests/utils/browser.ts`/`hooks.ts` from the installed version |
90
+ | `plum sync-scaffold` | Re-sync `tests/utils/browser.ts`/`hooks.ts` from the installed Plum version without a full update (old copies are backed up, never lost) |
91
+ | `plum node start` | Set up connectivity, start a runner node, and open the runner menu |
92
+ | `plum node restart` | Stop, refresh dependencies, and restart the runner node |
93
+ | `plum node stop` | Stop the runner node started from this folder |
94
+ | `plum node reconfig` | Re-enter node settings and re-register |
95
+ | `plum run-test` | Run all tests locally without Docker |
96
+ | `plum run-test @tag` | Run tests matching a tag |
97
+ | `plum run-test --parallel N` | Run tests across N parallel workers |
98
+ | `plum run-test --browser <b>` | Run in `chromium` (default) or `firefox` |
99
+ | `plum run-test --help` | Show usage for `run-test` |
100
+ | `plum create-step` | Interactively scaffold a new step definition |
101
+ | `plum manage-runners` | Open the interactive runner management menu |
101
102
 
102
103
  ---
103
104
 
@@ -15,68 +15,12 @@
15
15
  * along with Plum. If not, see https://www.gnu.org/licenses/.
16
16
  */
17
17
 
18
- import { chromium, firefox, webkit, Browser, BrowserContext, Page } from 'playwright';
19
- import * as fs from 'fs';
20
- import * as path from 'path';
18
+ // Thin pass-through to Plum's own recording wiring, kept inside the installed
19
+ // Plum package rather than copied here — this file exists so page objects
20
+ // have a stable `page()`/`context()` to import.
21
+ import type { Page, BrowserContext } from 'playwright';
21
22
 
22
- let _browser: Browser;
23
- let _context: BrowserContext;
24
- let _page: Page;
25
- let _ssCounter = 0;
23
+ const runtime = require(process.env.PLUM_RUNTIME_PATH as string);
26
24
 
27
- export const page = (): Page => _page;
28
-
29
- export async function setup(): Promise<void> {
30
- const isHeadless = process.env.IS_HEADLESS?.toLowerCase() !== 'false';
31
- const browserName = (process.env.BROWSER || 'chromium').toLowerCase();
32
- const browserType =
33
- browserName === 'firefox' ? firefox : browserName === 'webkit' ? webkit : chromium;
34
- _browser = await browserType.launch({ headless: isHeadless });
35
- _context = await _browser.newContext();
36
- _page = await _context.newPage();
37
- }
38
-
39
- export async function screenshotStep(
40
- attach: (data: Buffer, mime: string) => Promise<void>
41
- ): Promise<void> {
42
- if (!_page) return;
43
- try {
44
- const screenshot = await _page.screenshot({ type: 'png' });
45
- await attach(screenshot, 'image/png');
46
- } catch {
47
- // page may be navigating or already closed
48
- }
49
- }
50
-
51
- export async function streamLiveScreenshot(stepName: string): Promise<void> {
52
- const ssDir = process.env.PLUM_SS_DIR;
53
- if (!ssDir || !_page) return;
54
- try {
55
- const seq = `${String(Date.now()).padStart(16, '0')}-${String(++_ssCounter).padStart(4, '0')}`;
56
- const screenshot = await _page.screenshot({ type: 'jpeg', quality: 70 });
57
- fs.writeFileSync(
58
- path.join(ssDir, `${seq}.ss.json`),
59
- JSON.stringify({ stepName, data: screenshot.toString('base64') })
60
- );
61
- } catch {
62
- // ignore — live streaming is best-effort
63
- }
64
- }
65
-
66
- export async function teardown(
67
- attach: (data: Buffer, mime: string) => Promise<void>,
68
- failed: boolean
69
- ): Promise<void> {
70
- if (failed && _page) {
71
- const screenshotDir = 'reports/screenshots';
72
- if (!fs.existsSync(screenshotDir)) {
73
- fs.mkdirSync(screenshotDir, { recursive: true });
74
- }
75
- const screenshotPath = path.join(screenshotDir, `screenshot_${Date.now()}.png`);
76
- await _page.screenshot({ path: screenshotPath });
77
- const screenshotData = fs.readFileSync(screenshotPath);
78
- await attach(screenshotData, 'image/png');
79
- fs.unlinkSync(screenshotPath);
80
- }
81
- await _browser?.close();
82
- }
25
+ export const page = (): Page => runtime.page();
26
+ export const context = (): BrowserContext => runtime.context();
@@ -15,24 +15,9 @@
15
15
  * along with Plum. If not, see https://www.gnu.org/licenses/.
16
16
  */
17
17
 
18
- import { Before, After, AfterStep, ITestCaseHookParameter } from '@cucumber/cucumber';
19
- import { setup, teardown, screenshotStep, streamLiveScreenshot } from './browser';
20
- import dotenv from 'dotenv';
18
+ // Thin pass-through to Plum's own recording wiring, kept inside the installed
19
+ // Plum package rather than copied here.
20
+ require(process.env.PLUM_RUNTIME_PATH as string).registerHooks();
21
21
 
22
- dotenv.config();
23
-
24
- Before(async ({ pickle }: ITestCaseHookParameter) => {
25
- const tags = pickle.tags.map((t) => t.name).join(' ');
26
- console.log(`\n▶ ${pickle.name}${tags ? ` ${tags}` : ''}`);
27
- await setup();
28
- });
29
-
30
- AfterStep(async function ({ pickleStep, result }: { pickleStep: any; result: any }) {
31
- if (result?.status === 'SKIPPED') return;
32
- await screenshotStep(this.attach.bind(this));
33
- await streamLiveScreenshot(pickleStep?.text ?? '');
34
- });
35
-
36
- After(async function (scenario: ITestCaseHookParameter) {
37
- await teardown(this.attach.bind(this), scenario.result?.status === 'FAILED');
38
- });
22
+ // Add your own custom Before/After/BeforeStep hooks below — Cucumber runs
23
+ // every registered hook, so these run alongside Plum's own.
package/backend/app.js CHANGED
@@ -3,10 +3,8 @@
3
3
  * Licensed under the MIT License. See LICENSE file in the project root for details.
4
4
  */
5
5
 
6
- const path = require('path');
7
6
  const express = require('express');
8
7
  const cors = require('cors');
9
- const { SCREENSHOTS_DIR } = require('./lib/reportFilename');
10
8
  const { isNodeMode } = require('./constants/env');
11
9
  const app = express();
12
10
 
@@ -16,9 +14,6 @@ app.use(cors({ origin: '*' }));
16
14
  // before a real test suite does.
17
15
  app.use(express.json({ limit: '500mb' }));
18
16
 
19
- // Serve screenshot files written during report processing
20
- app.use('/screenshots', express.static(SCREENSHOTS_DIR));
21
-
22
17
  // Routes
23
18
  const nodeRoutes = require('./routes/node.routes');
24
19
  app.use('/api', nodeRoutes);
@@ -36,7 +36,7 @@ if (!process.env.DATABASE_URL) {
36
36
  const reportService = require('../../services/reportService');
37
37
  const triggerType = normaliseTrigger(process.env.TRIGGER);
38
38
  const rawTag = process.env.TAG || '@all-tests';
39
- const nodeCount = Math.max(
39
+ const workerCount = Math.max(
40
40
  1,
41
41
  parseInt(process.env.REPORT_RUNNERS || process.env.PARALLEL || '1', 10) || 1
42
42
  );
@@ -45,7 +45,7 @@ if (!process.env.DATABASE_URL) {
45
45
  rawCucumberJson: raw,
46
46
  tags: rawTag,
47
47
  triggerType,
48
- nodeCount,
48
+ workerCount,
49
49
  browser: process.env.BROWSER || DEFAULT_BROWSER,
50
50
  runnerName: process.env.RUNNER_NAME || null,
51
51
  runnerId: process.env.RUNNER_ID || null,
@@ -112,7 +112,11 @@ try {
112
112
  // When running from a temp dir there is no tsconfig.json above it, so
113
113
  // ts-node falls back to defaults that may conflict with the Node version.
114
114
  // Point it at the backend tsconfig explicitly.
115
- ...(execCwd && { TS_NODE_PROJECT: path.resolve(__dirname, '..', '..', 'tsconfig.json') })
115
+ ...(execCwd && { TS_NODE_PROJECT: path.resolve(__dirname, '..', '..', 'tsconfig.json') }),
116
+ // tests/utils/browser.ts and hooks.ts are a thin pass-through to this —
117
+ // an absolute path works the same whether cwd is backend/ (local run) or
118
+ // a temp dir with no relation to backend/ (a dispatched node run).
119
+ PLUM_RUNTIME_PATH: path.resolve(__dirname, '..', '..', 'lib', 'plumTestRuntime.js')
116
120
  }
117
121
  });
118
122
  } catch (error) {
@@ -15,23 +15,26 @@ const SOCKET_EVENTS = Object.freeze({
15
15
  CANCEL_TEST: 'cancel-test',
16
16
  LOG: 'log',
17
17
  DONE: 'done',
18
- STEP_SCREENSHOT: 'step-screenshot',
19
18
 
20
19
  // Multi-lane distributed run (single interactive run, several runners)
21
20
  RUNNER_LANES_INIT: 'runner-lanes-init',
22
21
  RUNNER_LANE_LOG: 'runner-lane-log',
23
22
  RUNNER_LANE_STATUS: 'runner-lane-status',
24
- RUNNER_LANE_SCREENSHOT: 'runner-lane-screenshot',
25
23
 
26
24
  // Background runs (cron / REST / MCP triggered, no single owning socket)
27
25
  BG_RUN_START: 'bg-run-start',
28
26
  BG_RUN_LOG: 'bg-run-log',
29
27
  BG_RUN_DONE: 'bg-run-done',
30
- BG_RUN_SCREENSHOT: 'bg-run-screenshot',
31
28
  BG_RUN_LANES_INIT: 'bg-run-lanes-init',
32
29
  BG_RUN_LANE_LOG: 'bg-run-lane-log',
33
30
  BG_RUN_LANE_STATUS: 'bg-run-lane-status',
34
- BG_RUN_LANE_SCREENSHOT: 'bg-run-lane-screenshot',
31
+
32
+ // Live rrweb streaming — one shape for every run type, always
33
+ // carrying a lane id (BUILT_IN_RUNNER_ID for the plain single-run case) and
34
+ // a workerId, so a single built-in run with --parallel workers is finally
35
+ // attributable per worker instead of one flat interleaved stream.
36
+ RUNNER_LANE_RRWEB_BATCH: 'runner-lane-rrweb-batch',
37
+ BG_RUN_LANE_RRWEB_BATCH: 'bg-run-lane-rrweb-batch',
35
38
 
36
39
  // Global notifications (any client, not tied to a specific run)
37
40
  REPORT_READY: 'report-ready'
@@ -0,0 +1,262 @@
1
+ /*
2
+ * This file is part of Plum.
3
+ * Licensed under the MIT License. See LICENSE file in the project root for details.
4
+ */
5
+
6
+ // The actual implementation behind tests/utils/browser.ts and hooks.ts.
7
+ // Customer test projects only get a thin pass-through to this module (see
8
+ // backend/_scaffold/utils/) — keeping the real wiring here means every
9
+ // `npm install -g plum-e2e@latest` picks up fixes/changes immediately,
10
+ // without needing to re-sync anything into an existing customer project.
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const zlib = require('zlib');
15
+ const dotenv = require('dotenv');
16
+ const { chromium, firefox, webkit } = require('playwright');
17
+ const { Before, After, BeforeStep } = require('@cucumber/cucumber');
18
+
19
+ dotenv.config();
20
+
21
+ // Must match the mime type Plum's server expects — do not change.
22
+ const RRWEB_MIME_TYPE = 'application/x-plum-rrweb+json';
23
+ // Always attached, even for a scenario with no recorded events, so the
24
+ // worker that ran it is still recoverable for grouping.
25
+ const WORKER_META_MIME_TYPE = 'application/x-plum-worker+json';
26
+ // @rrweb/record's package.json only exports its main entry ("."), so a deep
27
+ // require.resolve() of the UMD bundle is blocked by Node's exports map — resolve
28
+ // the (exported) main entry instead and locate the sibling file on disk.
29
+ const RECORD_BUNDLE_PATH = path.join(
30
+ path.dirname(require.resolve('@rrweb/record')),
31
+ 'record.umd.min.cjs'
32
+ );
33
+
34
+ let _browser;
35
+ let _context;
36
+ let _page;
37
+ let _liveRRwebCounter = 0;
38
+ let _liveRRwebTimer = null;
39
+ let _tabs = new Map();
40
+ let _tabCounter = 0;
41
+ let _workerId = 1;
42
+
43
+ const page = () => _page;
44
+ const context = () => _context;
45
+
46
+ function tabIdForIndex(index) {
47
+ return index === 0 ? 'main' : `tab-${index + 1}`;
48
+ }
49
+
50
+ // A static page (nothing left to interact with) can go a long time between
51
+ // rrweb events, or emit none at all after its initial load — its own event
52
+ // timestamps are a poor proxy for how long it stayed relevant. Real
53
+ // open/close times let the replay UI line multiple tabs up on one timeline
54
+ // without guessing from event gaps.
55
+ function attachRecorder(pg) {
56
+ const tabIndex = _tabCounter++;
57
+ const recording = {
58
+ tabId: tabIdForIndex(tabIndex),
59
+ tabIndex,
60
+ events: [],
61
+ openedAt: Date.now(),
62
+ closedAt: null,
63
+ liveFlushedCount: 0
64
+ };
65
+ _tabs.set(pg, recording);
66
+ pg.on('close', () => {
67
+ recording.closedAt = Date.now();
68
+ });
69
+ }
70
+
71
+ async function setup() {
72
+ const isHeadless = process.env.IS_HEADLESS?.toLowerCase() !== 'false';
73
+ const browserName = (process.env.BROWSER || 'chromium').toLowerCase();
74
+ const browserType =
75
+ browserName === 'firefox' ? firefox : browserName === 'webkit' ? webkit : chromium;
76
+ _browser = await browserType.launch({ headless: isHeadless });
77
+ _context = await _browser.newContext();
78
+
79
+ _tabs = new Map();
80
+ _tabCounter = 0;
81
+ // Cucumber forks one OS process per --parallel worker and injects this env
82
+ // var into each — 0-indexed, so display/report as 1-based like the rest of
83
+ // the worker-count UI.
84
+ const parsedWorkerId = parseInt(process.env.CUCUMBER_WORKER_ID ?? '', 10);
85
+ _workerId = Number.isFinite(parsedWorkerId) ? parsedWorkerId + 1 : 1;
86
+
87
+ // Context-level exposeBinding/addInitScript apply to every page in the
88
+ // context automatically — current and future (popups, target=_blank tabs) —
89
+ // so recording setup never races a new tab's first navigation.
90
+ await _context.exposeBinding('__plumEmitRRwebEvent', (source, eventJson) => {
91
+ const recording = source.page && _tabs.get(source.page);
92
+ if (!recording) return;
93
+ try {
94
+ recording.events.push(JSON.parse(eventJson));
95
+ } catch {
96
+ // malformed event — drop it, recording is best-effort
97
+ }
98
+ });
99
+ await _context.addInitScript({ path: RECORD_BUNDLE_PATH });
100
+ await _context.addInitScript(() => {
101
+ // addInitScript runs in every frame, including hidden ad/tracking iframes.
102
+ // Recordings are tracked per-Page, so an unguarded sub-frame session would
103
+ // corrupt the tab's event stream with bogus 0x0 "about:blank" entries.
104
+ if (window.self !== window.top) return;
105
+ if (window.rrwebRecord) {
106
+ window.rrwebRecord.record({
107
+ emit: (event) => {
108
+ // exposed by BrowserContext.exposeBinding above
109
+ window.__plumEmitRRwebEvent(JSON.stringify(event));
110
+ }
111
+ });
112
+ }
113
+ });
114
+
115
+ _context.on('page', attachRecorder);
116
+ _page = await _context.newPage();
117
+
118
+ // Only when someone's actually watching live — a scheduled/background run
119
+ // with no viewer shouldn't pay for this.
120
+ if (process.env.PLUM_SS_DIR) {
121
+ _liveRRwebTimer = setInterval(flushLiveRRwebEvents, 500);
122
+ }
123
+ }
124
+
125
+ // Sends only what's newly arrived since the last tick, per tab, so the live
126
+ // viewer gets a steady trickle instead of the full buffer growing unbounded.
127
+ function flushLiveRRwebEvents() {
128
+ const ssDir = process.env.PLUM_SS_DIR;
129
+ if (!ssDir) return;
130
+ for (const recording of _tabs.values()) {
131
+ const newEvents = recording.events.slice(recording.liveFlushedCount);
132
+ if (newEvents.length === 0) continue;
133
+ recording.liveFlushedCount = recording.events.length;
134
+ try {
135
+ const seq = `${String(Date.now()).padStart(16, '0')}-${String(++_liveRRwebCounter).padStart(4, '0')}`;
136
+ fs.writeFileSync(
137
+ path.join(ssDir, `${seq}.rrweb.json`),
138
+ JSON.stringify({
139
+ workerId: _workerId,
140
+ tabId: recording.tabId,
141
+ tabIndex: recording.tabIndex,
142
+ events: newEvents
143
+ })
144
+ );
145
+ } catch {
146
+ // best-effort — live streaming shouldn't affect the recording itself
147
+ }
148
+ }
149
+ }
150
+
151
+ // Injects a labeled rrweb custom event at the current recording timestamp so
152
+ // the replay UI can show which step was running at any point in the timeline.
153
+ async function markStepStart(stepName) {
154
+ if (!_page) return;
155
+ try {
156
+ await _page.evaluate((name) => {
157
+ if (window.rrwebRecord?.record?.addCustomEvent) {
158
+ window.rrwebRecord.record.addCustomEvent('step', { name });
159
+ }
160
+ }, stepName);
161
+ } catch {
162
+ // best-effort — a missing marker just means the replay UI won't show a
163
+ // step label at that point, it doesn't affect the recording itself
164
+ }
165
+ }
166
+
167
+ // Flushes every tab's buffered rrweb events (one per opened tab/popup) as a
168
+ // gzip-compressed Cucumber attachment, tagged with the mime type Plum's
169
+ // server looks for.
170
+ async function flushRecordings(attach) {
171
+ if (_liveRRwebTimer) {
172
+ clearInterval(_liveRRwebTimer);
173
+ _liveRRwebTimer = null;
174
+ }
175
+ // One last live flush so the stream doesn't miss whatever happened between
176
+ // the final tick and scenario end.
177
+ flushLiveRRwebEvents();
178
+
179
+ try {
180
+ await attach(
181
+ Buffer.from(JSON.stringify({ workerId: _workerId }), 'utf8'),
182
+ WORKER_META_MIME_TYPE
183
+ );
184
+ } catch {
185
+ // best-effort — a missing worker marker just falls back to workerId 1
186
+ }
187
+
188
+ const flushedAt = Date.now();
189
+ for (const recording of _tabs.values()) {
190
+ if (recording.events.length === 0) continue;
191
+ try {
192
+ const payload = JSON.stringify({
193
+ workerId: _workerId,
194
+ tabId: recording.tabId,
195
+ tabIndex: recording.tabIndex,
196
+ events: recording.events,
197
+ openedAt: recording.openedAt,
198
+ // A tab still open when the scenario ends (typically the main tab)
199
+ // stayed relevant through to the flush, not just its last DOM event.
200
+ closedAt: recording.closedAt ?? flushedAt
201
+ });
202
+ const gz = zlib.gzipSync(Buffer.from(payload, 'utf8'));
203
+ await attach(gz, RRWEB_MIME_TYPE);
204
+ } catch {
205
+ // a failed recording flush shouldn't fail the scenario
206
+ }
207
+ }
208
+ }
209
+
210
+ async function teardown() {
211
+ await _browser?.close();
212
+ }
213
+
214
+ // Pickle steps carry no keyword (Cucumber normalizes Given/When/Then/And/But
215
+ // away during Gherkin → Pickle compilation) — recover it by walking the
216
+ // gherkinDocument for the AST node the pickle step was compiled from.
217
+ function resolveStepKeyword(gherkinDocument, pickleStep) {
218
+ const astNodeId = pickleStep?.astNodeIds?.[0];
219
+ if (!astNodeId) return '';
220
+ const steps = [];
221
+ for (const child of gherkinDocument?.feature?.children ?? []) {
222
+ if (child.background) steps.push(...child.background.steps);
223
+ if (child.scenario) steps.push(...child.scenario.steps);
224
+ for (const ruleChild of child.rule?.children ?? []) {
225
+ if (ruleChild.background) steps.push(...ruleChild.background.steps);
226
+ if (ruleChild.scenario) steps.push(...ruleChild.scenario.steps);
227
+ }
228
+ }
229
+ return steps.find((s) => s.id === astNodeId)?.keyword?.trim() ?? '';
230
+ }
231
+
232
+ // Registers Plum's own Before/BeforeStep/After hooks. Call once from the
233
+ // project's own tests/utils/hooks.ts — Cucumber supports multiple Before/After
234
+ // hooks, so a customer's own hooks can still be added alongside this.
235
+ function registerHooks() {
236
+ Before(async ({ pickle }) => {
237
+ const tags = pickle.tags.map((t) => t.name).join(' ');
238
+ console.log(`\n▶ ${pickle.name}${tags ? ` ${tags}` : ''}`);
239
+ await setup();
240
+ });
241
+
242
+ BeforeStep(async function ({ pickleStep, gherkinDocument }) {
243
+ const keyword = resolveStepKeyword(gherkinDocument, pickleStep);
244
+ const text = pickleStep?.text ?? '';
245
+ await markStepStart(keyword ? `${keyword} ${text}` : text);
246
+ });
247
+
248
+ After(async function () {
249
+ await flushRecordings(this.attach.bind(this));
250
+ await teardown();
251
+ });
252
+ }
253
+
254
+ module.exports = {
255
+ page,
256
+ context,
257
+ setup,
258
+ teardown,
259
+ flushRecordings,
260
+ markStepStart,
261
+ registerHooks
262
+ };
@@ -7,7 +7,6 @@ const path = require('path');
7
7
  const fs = require('fs');
8
8
 
9
9
  const REPORTS_DIR = path.resolve(process.cwd(), 'reports');
10
- const SCREENSHOTS_DIR = path.join(REPORTS_DIR, 'screenshots');
11
10
 
12
11
  /**
13
12
  * Reads the transient cucumber_report.json written by the most recent local test run.
@@ -22,4 +21,4 @@ function readCucumberReportFile() {
22
21
  }
23
22
  }
24
23
 
25
- module.exports = { REPORTS_DIR, SCREENSHOTS_DIR, readCucumberReportFile };
24
+ module.exports = { REPORTS_DIR, readCucumberReportFile };
@@ -6,20 +6,23 @@
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
8
 
9
- function startSsPoller(ssDir, onScreenshot) {
9
+ // The test process runs several levels below whatever calls this, with no
10
+ // direct pipe back, so rrweb batches live-stream by small files dropped and
11
+ // picked up here.
12
+ function startRRwebPoller(ssDir, onRRwebBatch) {
10
13
  const seenFiles = new Set();
11
14
  return setInterval(() => {
12
15
  try {
13
16
  const files = fs
14
17
  .readdirSync(ssDir)
15
- .filter((f) => f.endsWith('.ss.json'))
18
+ .filter((f) => f.endsWith('.rrweb.json'))
16
19
  .sort();
17
20
  for (const f of files) {
18
21
  if (seenFiles.has(f)) continue;
19
22
  seenFiles.add(f);
20
23
  const filePath = path.join(ssDir, f);
21
24
  const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
22
- onScreenshot(data);
25
+ onRRwebBatch?.(data);
23
26
  try {
24
27
  fs.unlinkSync(filePath);
25
28
  } catch {}
@@ -28,4 +31,4 @@ function startSsPoller(ssDir, onScreenshot) {
28
31
  }, 400);
29
32
  }
30
33
 
31
- module.exports = { startSsPoller };
34
+ module.exports = { startRRwebPoller };
@@ -43,10 +43,12 @@ function wireRealtimeServices(io, isNodeMode) {
43
43
  if (isNodeMode) return { cronService: null, backupCronService: null };
44
44
 
45
45
  const socketHandler = require('../websockets/socketHandler.js');
46
+ const nodeSocketHandler = require('../websockets/nodeSocketHandler.js');
46
47
  const cronService = require('../services/cronService');
47
48
  const backupCronService = require('../services/backupCronService');
48
49
 
49
50
  socketHandler(io);
51
+ nodeSocketHandler(io);
50
52
  cronService.setSocketIO(io);
51
53
  require('../routes/trigger.routes').setSocketIO(io);
52
54
 
@@ -125,6 +127,7 @@ function handleNodeModeStartup(port) {
125
127
 
126
128
  async function handleFullModeStartup(io, testsDir) {
127
129
  syncAutomatedFlags();
130
+ cleanupLegacyScreenshots();
128
131
 
129
132
  const chokidar = await loadChokidar();
130
133
  if (!chokidar) return;
@@ -133,6 +136,17 @@ async function handleFullModeStartup(io, testsDir) {
133
136
  watchReports(chokidar, io);
134
137
  }
135
138
 
139
+ // Report rows no longer reference screenshot files (replaced by rrweb
140
+ // recordings), so any leftover files on disk are dead weight. Safe to run
141
+ // every startup: a second pass on an already-gone directory is a no-op.
142
+ function cleanupLegacyScreenshots() {
143
+ const screenshotsDir = path.join(process.cwd(), 'reports', 'screenshots');
144
+ if (!fs.existsSync(screenshotsDir)) return;
145
+ fs.rm(screenshotsDir, { recursive: true, force: true }, (err) => {
146
+ if (!err) console.log('🧹 Removed legacy screenshots directory');
147
+ });
148
+ }
149
+
136
150
  function syncAutomatedFlags() {
137
151
  // Sync automated flags from feature files on every startup
138
152
  require('../services/reportService')
@@ -0,0 +1,8 @@
1
+ /*
2
+ This file is part of Plum.
3
+ Licensed under the MIT License. See LICENSE file in the project root for details.
4
+ */
5
+ 📂 Loading tests from: /Users/silverlunah/Projects/plum/backend/tests
6
+ Backend running on port 3099 (node/runner mode)
7
+ (node:10858) [DEP0190] DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated.
8
+ (Use `node --trace-deprecation ...` to show where the warning was created)