plum-e2e 2.9.4 → 2.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -79,33 +79,25 @@ 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, then restart each registered server/node on this machine (asks before each, in an interactive shell) |
90
- | `plum sync-scaffold` | Check `tests/utils/browser.ts`/`hooks.ts` against Plum's recommended starter pattern; reports what's stale but changes nothing (rarely needed — see below) |
91
- | `plum sync-scaffold --force` | Overwrite files reported stale by the above (previous version backed up first) |
92
- | `plum node start` | Set up connectivity, start a runner node, and open the runner menu |
93
- | `plum node restart` | Stop, refresh dependencies, and restart the runner node |
94
- | `plum node stop` | Stop the runner node started from this folder |
95
- | `plum node reconfig` | Re-enter node settings and re-register |
96
- | `plum run-test` | Run all tests locally without Docker |
97
- | `plum run-test @tag` | Run tests matching a tag |
98
- | `plum run-test --parallel N` | Run tests across N parallel workers |
99
- | `plum run-test --browser <b>` | Run in `chromium` (default) or `firefox` |
100
- | `plum run-test --help` | Show usage for `run-test` |
101
- | `plum create-step` | Interactively scaffold a new step definition |
102
- | `plum manage-runners` | Open the interactive runner management menu |
103
-
104
- ---
105
-
106
- ## `tests/utils/plum-modules/`
107
-
108
- `tests/utils/browser.ts` and `hooks.ts` are yours — customize them freely, they're never touched automatically. They import Plum's session recording from `tests/utils/plum-modules/`, which is regenerated from the installed Plum version before every run (`plum run-test`, `plum node start`, and every run triggered from the web UI), so recording fixes and features reach existing projects without any update step. Don't edit anything inside `plum-modules/` — it's overwritten on the next run.
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, then restart each registered server/node on this machine (asks before each, in an interactive shell) |
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 |
109
101
 
110
102
  ---
111
103
 
@@ -15,11 +15,229 @@
15
15
  * along with Plum. If not, see https://www.gnu.org/licenses/.
16
16
  */
17
17
 
18
- import type { Page, BrowserContext, Browser } from 'playwright';
19
- import * as plum from './plum-modules/runtime';
18
+ // Wires up Plum's session recording removing or reordering code here can silently break report replay.
20
19
 
21
- export const page = (): Page => plum.page();
22
- export const context = (): BrowserContext => plum.context();
23
- export const browser = (): Browser => plum.browser();
20
+ import { chromium, firefox, webkit, Browser, BrowserContext, Page } from 'playwright';
21
+ import * as fs from 'fs';
22
+ import * as path from 'path';
23
+ import * as zlib from 'zlib';
24
24
 
25
- // Add your own page/context helpers below.
25
+ // Must match the mime type Plum's server expects — do not change.
26
+ const RRWEB_MIME_TYPE = 'application/x-plum-rrweb+json';
27
+ // Always attached, even for a scenario with no recorded events, so the
28
+ // worker that ran it is still recoverable for grouping.
29
+ const WORKER_META_MIME_TYPE = 'application/x-plum-worker+json';
30
+ // @rrweb/record's package.json only exports its main entry ("."), so a deep
31
+ // require.resolve() of the UMD bundle is blocked by Node's exports map — resolve
32
+ // the (exported) main entry instead and locate the sibling file on disk.
33
+ const RECORD_BUNDLE_PATH = path.join(
34
+ path.dirname(require.resolve('@rrweb/record')),
35
+ 'record.umd.min.cjs'
36
+ );
37
+
38
+ interface TabRecording {
39
+ tabId: string;
40
+ tabIndex: number;
41
+ events: unknown[];
42
+ openedAt: number;
43
+ closedAt: number | null;
44
+ liveFlushedCount: number;
45
+ }
46
+
47
+ let _browser: Browser;
48
+ let _context: BrowserContext;
49
+ let _page: Page;
50
+ let _liveRRwebCounter = 0;
51
+ let _liveRRwebTimer: ReturnType<typeof setInterval> | null = null;
52
+ let _tabs: Map<Page, TabRecording> = new Map();
53
+ let _tabCounter = 0;
54
+ let _workerId = 1;
55
+
56
+ export const page = (): Page => _page;
57
+ export const context = (): BrowserContext => _context;
58
+
59
+ function tabIdForIndex(index: number): string {
60
+ return index === 0 ? 'main' : `tab-${index + 1}`;
61
+ }
62
+
63
+ // A static page (nothing left to interact with) can go a long time between
64
+ // rrweb events, or emit none at all after its initial load — its own event
65
+ // timestamps are a poor proxy for how long it stayed relevant. Real
66
+ // open/close times let the replay UI line multiple tabs up on one timeline
67
+ // without guessing from event gaps.
68
+ function attachRecorder(pg: Page): void {
69
+ const tabIndex = _tabCounter++;
70
+ const recording: TabRecording = {
71
+ tabId: tabIdForIndex(tabIndex),
72
+ tabIndex,
73
+ events: [],
74
+ openedAt: Date.now(),
75
+ closedAt: null,
76
+ liveFlushedCount: 0
77
+ };
78
+ _tabs.set(pg, recording);
79
+ pg.on('close', () => {
80
+ recording.closedAt = Date.now();
81
+ });
82
+ }
83
+
84
+ export async function setup(): Promise<void> {
85
+ const isHeadless = process.env.IS_HEADLESS?.toLowerCase() !== 'false';
86
+ const browserName = (process.env.BROWSER || 'chromium').toLowerCase();
87
+ const browserType =
88
+ browserName === 'firefox' ? firefox : browserName === 'webkit' ? webkit : chromium;
89
+ _browser = await browserType.launch({ headless: isHeadless });
90
+ _context = await _browser.newContext();
91
+
92
+ _tabs = new Map();
93
+ _tabCounter = 0;
94
+ // Cucumber forks one OS process per --parallel worker and injects this env
95
+ // var into each — 0-indexed, so display/report as 1-based like the rest of
96
+ // the worker-count UI.
97
+ const parsedWorkerId = parseInt(process.env.CUCUMBER_WORKER_ID ?? '', 10);
98
+ _workerId = Number.isFinite(parsedWorkerId) ? parsedWorkerId + 1 : 1;
99
+
100
+ // Context-level exposeBinding/addInitScript apply to every page in the
101
+ // context automatically — current and future (popups, target=_blank tabs) —
102
+ // so recording setup never races a new tab's first navigation.
103
+ await _context.exposeBinding('__plumEmitRRwebEvent', (source, eventJson: string) => {
104
+ const recording = source.page && _tabs.get(source.page);
105
+ if (!recording) return;
106
+ try {
107
+ recording.events.push(JSON.parse(eventJson));
108
+ } catch {
109
+ // malformed event — drop it, recording is best-effort
110
+ }
111
+ });
112
+ await _context.addInitScript({ path: RECORD_BUNDLE_PATH });
113
+ await _context.addInitScript(() => {
114
+ // addInitScript runs in every frame, including hidden ad/tracking iframes.
115
+ // Recordings are tracked per-Page, so an unguarded sub-frame session would
116
+ // corrupt the tab's event stream with bogus 0x0 "about:blank" entries.
117
+ // @ts-ignore
118
+ if (window.self !== window.top) return;
119
+ // @ts-ignore
120
+ if (window.rrwebRecord) {
121
+ // @ts-ignore
122
+ window.rrwebRecord.record({
123
+ emit: (event: unknown) => {
124
+ // @ts-ignore — exposed by BrowserContext.exposeBinding above
125
+ window.__plumEmitRRwebEvent(JSON.stringify(event));
126
+ }
127
+ });
128
+ }
129
+ });
130
+
131
+ _context.on('page', attachRecorder);
132
+ _page = await _context.newPage();
133
+
134
+ // Only when someone's actually watching live — a scheduled/background run
135
+ // with no viewer shouldn't pay for this.
136
+ if (process.env.PLUM_SS_DIR) {
137
+ _liveRRwebTimer = setInterval(flushLiveRRwebEvents, 500);
138
+ }
139
+ }
140
+
141
+ // Sends only what's newly arrived since the last tick, per tab, so the live
142
+ // viewer gets a steady trickle instead of the full buffer growing unbounded.
143
+ function flushLiveRRwebEvents(): void {
144
+ const ssDir = process.env.PLUM_SS_DIR;
145
+ if (!ssDir) return;
146
+ for (const recording of _tabs.values()) {
147
+ const newEvents = recording.events.slice(recording.liveFlushedCount);
148
+ if (newEvents.length === 0) continue;
149
+ recording.liveFlushedCount = recording.events.length;
150
+ try {
151
+ const seq = `${String(Date.now()).padStart(16, '0')}-${String(++_liveRRwebCounter).padStart(4, '0')}`;
152
+ fs.writeFileSync(
153
+ path.join(ssDir, `${seq}.rrweb.json`),
154
+ JSON.stringify({
155
+ workerId: _workerId,
156
+ tabId: recording.tabId,
157
+ tabIndex: recording.tabIndex,
158
+ events: newEvents
159
+ })
160
+ );
161
+ } catch {
162
+ // best-effort — live streaming shouldn't affect the recording itself
163
+ }
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Injects a labeled rrweb custom event at the current recording timestamp so
169
+ * the replay UI can show which step was running at any point in the timeline.
170
+ */
171
+ export async function markStepStart(stepName: string): Promise<void> {
172
+ if (!_page) return;
173
+ try {
174
+ await _page.evaluate((name) => {
175
+ // @ts-ignore — rrwebRecord is injected by the record.umd.min.cjs bundle
176
+ if (window.rrwebRecord?.record?.addCustomEvent) {
177
+ // @ts-ignore
178
+ window.rrwebRecord.record.addCustomEvent('step', { name });
179
+ }
180
+ }, stepName);
181
+ } catch {
182
+ // best-effort — a missing marker just means the replay UI won't show a
183
+ // step label at that point, it doesn't affect the recording itself
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Flushes every tab's buffered rrweb events (one per opened tab/popup) as a
189
+ * gzip-compressed Cucumber attachment, tagged with the mime type Plum's
190
+ * server looks for.
191
+ */
192
+ export async function flushRecordings(
193
+ attach: (data: Buffer, mime: string) => Promise<void>
194
+ ): Promise<void> {
195
+ if (_liveRRwebTimer) {
196
+ clearInterval(_liveRRwebTimer);
197
+ _liveRRwebTimer = null;
198
+ }
199
+ // One last live flush so the stream doesn't miss whatever happened between
200
+ // the final tick and scenario end.
201
+ flushLiveRRwebEvents();
202
+
203
+ try {
204
+ await attach(
205
+ Buffer.from(JSON.stringify({ workerId: _workerId }), 'utf8'),
206
+ WORKER_META_MIME_TYPE
207
+ );
208
+ } catch {
209
+ // best-effort — a missing worker marker just falls back to workerId 1
210
+ }
211
+
212
+ const flushedAt = Date.now();
213
+ for (const recording of _tabs.values()) {
214
+ if (recording.events.length === 0) continue;
215
+ try {
216
+ const payload = JSON.stringify({
217
+ workerId: _workerId,
218
+ tabId: recording.tabId,
219
+ tabIndex: recording.tabIndex,
220
+ events: recording.events,
221
+ openedAt: recording.openedAt,
222
+ // A tab still open when the scenario ends (typically the main tab)
223
+ // stayed relevant through to the flush, not just its last DOM event.
224
+ closedAt: recording.closedAt ?? flushedAt
225
+ });
226
+ const gz = zlib.gzipSync(Buffer.from(payload, 'utf8'));
227
+ await attach(gz, RRWEB_MIME_TYPE);
228
+ } catch {
229
+ // a failed recording flush shouldn't fail the scenario
230
+ }
231
+ }
232
+ }
233
+
234
+ export async function teardown(): Promise<void> {
235
+ await _browser?.close();
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Your code below this line. Everything above wires up Plum's session
240
+ // recording — leave it as-is. Add your own page/context helpers here, built
241
+ // on the exported page()/context() above (e.g. a helper for a second tab or
242
+ // a second browser context).
243
+ // ---------------------------------------------------------------------------
@@ -15,9 +15,59 @@
15
15
  * along with Plum. If not, see https://www.gnu.org/licenses/.
16
16
  */
17
17
 
18
- import * as plum from './plum-modules/runtime';
18
+ // Wires up Plum's session recording — removing or reordering code here can silently break report replay.
19
19
 
20
- plum.registerHooks();
20
+ import { Before, After, BeforeStep, ITestCaseHookParameter } from '@cucumber/cucumber';
21
+ import { setup, teardown, flushRecordings, markStepStart } from './browser';
22
+ import dotenv from 'dotenv';
21
23
 
22
- // Add your own custom Before/After/BeforeStep hooks below — Cucumber runs
23
- // every registered hook, so these run alongside Plum's own.
24
+ dotenv.config();
25
+
26
+ /**
27
+ * Pickle steps carry no keyword (Cucumber normalizes Given/When/Then/And/But
28
+ * away during Gherkin → Pickle compilation) — recover it by walking the
29
+ * gherkinDocument for the AST node the pickle step was compiled from.
30
+ */
31
+ function resolveStepKeyword(gherkinDocument: any, pickleStep: any): string {
32
+ const astNodeId = pickleStep?.astNodeIds?.[0];
33
+ if (!astNodeId) return '';
34
+ const steps: any[] = [];
35
+ for (const child of gherkinDocument?.feature?.children ?? []) {
36
+ if (child.background) steps.push(...child.background.steps);
37
+ if (child.scenario) steps.push(...child.scenario.steps);
38
+ for (const ruleChild of child.rule?.children ?? []) {
39
+ if (ruleChild.background) steps.push(...ruleChild.background.steps);
40
+ if (ruleChild.scenario) steps.push(...ruleChild.scenario.steps);
41
+ }
42
+ }
43
+ return steps.find((s) => s.id === astNodeId)?.keyword?.trim() ?? '';
44
+ }
45
+
46
+ Before(async ({ pickle }: ITestCaseHookParameter) => {
47
+ const tags = pickle.tags.map((t) => t.name).join(' ');
48
+ console.log(`\n▶ ${pickle.name}${tags ? ` ${tags}` : ''}`);
49
+ await setup();
50
+ });
51
+
52
+ BeforeStep(async function ({
53
+ pickleStep,
54
+ gherkinDocument
55
+ }: {
56
+ pickleStep: any;
57
+ gherkinDocument: any;
58
+ }) {
59
+ const keyword = resolveStepKeyword(gherkinDocument, pickleStep);
60
+ const text = pickleStep?.text ?? '';
61
+ await markStepStart(keyword ? `${keyword} ${text}` : text);
62
+ });
63
+
64
+ After(async function () {
65
+ await flushRecordings(this.attach.bind(this));
66
+ await teardown();
67
+ });
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Your code below this line. Everything above wires up Plum's session
71
+ // recording — leave it as-is. Add your own Before/After/BeforeStep hooks
72
+ // here; Cucumber runs every registered hook, so yours run alongside Plum's.
73
+ // ---------------------------------------------------------------------------
@@ -29,19 +29,6 @@ let testExitCode = 0;
29
29
 
30
30
  try {
31
31
  const testsRoot = (process.env.TESTS_ROOT || 'tests').replace(/\\/g, '/');
32
- const testsRootAbsForSync = path.isAbsolute(testsRoot)
33
- ? testsRoot
34
- : path.resolve(process.cwd(), testsRoot);
35
-
36
- // tests/utils/browser.ts and hooks.ts import Plum's recording wiring from
37
- // here — always overwritten from whatever Plum version is actually running
38
- // this script, so it's never stale and never something a customer can
39
- // accidentally lose track of by not re-running an update command. Safe to
40
- // force unconditionally: nothing customer-owned ever lives in this folder.
41
- const plumModulesSrc = path.join(__dirname, '..', '..', '_scaffold', 'utils', 'plum-modules');
42
- const plumModulesDest = path.join(testsRootAbsForSync, 'utils', 'plum-modules');
43
- fs.rmSync(plumModulesDest, { recursive: true, force: true });
44
- fs.cpSync(plumModulesSrc, plumModulesDest, { recursive: true });
45
32
 
46
33
  // Dispatched tests run from an external dir (e.g. a temp dir on a node) that has
47
34
  // no node_modules of its own — point Node at the backend's modules so imports
@@ -11,7 +11,7 @@ const path = require('path');
11
11
  // picked up here.
12
12
  function startRRwebPoller(ssDir, onRRwebBatch) {
13
13
  const seenFiles = new Set();
14
- return setInterval(() => {
14
+ function drain() {
15
15
  try {
16
16
  const files = fs
17
17
  .readdirSync(ssDir)
@@ -28,7 +28,12 @@ function startRRwebPoller(ssDir, onRRwebBatch) {
28
28
  } catch {}
29
29
  }
30
30
  } catch {}
31
- }, 400);
31
+ }
32
+
33
+ const interval = setInterval(drain, 400);
34
+ // A scenario faster than one 400ms tick can exit before the interval ever
35
+ // fires — stop() drains once more so that last batch isn't dropped.
36
+ return { stop: () => (clearInterval(interval), drain()) };
32
37
  }
33
38
 
34
39
  module.exports = { startRRwebPoller };
@@ -60,7 +60,8 @@ router.get('/report/:jobId', authGuard, (req, res) => {
60
60
  // Poll job status and streamed logs
61
61
  router.get('/execute/:jobId', authGuard, (req, res) => {
62
62
  const offset = parseInt(req.query.offset || '0', 10);
63
- const result = nodeExecutionService.pollJob(req.params.jobId, offset);
63
+ const rrwebOffset = parseInt(req.query.rrwebOffset || '0', 10);
64
+ const result = nodeExecutionService.pollJob(req.params.jobId, offset, rrwebOffset);
64
65
  if (!result) return res.status(404).json({ error: 'Job not found' });
65
66
  res.json(result);
66
67
  });
@@ -101,7 +101,7 @@ function runSingleBuiltInAttempt({ taskName, currentTag, workers, browser, suppr
101
101
  onLog(`[ERROR] ${d.toString()}`);
102
102
  });
103
103
  task.on('close', (code) => {
104
- clearInterval(ssPoller);
104
+ ssPoller.stop();
105
105
  fs.rm(ssDir, { recursive: true, force: true }, () => {});
106
106
  resolve({ code, raw: suppressSave ? readCucumberReportFile() : null });
107
107
  });
@@ -379,7 +379,7 @@ async function runDistributed({
379
379
  onLog(`[ERROR] ${d.toString()}`);
380
380
  });
381
381
  task.on('close', (code) => {
382
- clearInterval(ssPoller);
382
+ ssPoller.stop();
383
383
  fs.rm(ssDir, { recursive: true, force: true }, () => {});
384
384
  const raw = readCucumberReportFile() ?? '[]';
385
385
  resolve({ code, rawJson: JSON.parse(raw) });
@@ -82,6 +82,9 @@ function startJob({
82
82
  jobs[jobId] = {
83
83
  status: JOB_STATUS.RUNNING,
84
84
  logs: '',
85
+ // Drained by pollJob — the HTTP-poll fallback for a node with no reachable
86
+ // notifyPublicUrl to stream these back over a socket instead.
87
+ rrwebBatches: [],
85
88
  exitCode: null,
86
89
  startedAt: Date.now(),
87
90
  meta: { tags: tags || '', browser, workers },
@@ -108,6 +111,7 @@ function startJob({
108
111
  if (workers > 1) env.PARALLEL = String(workers);
109
112
 
110
113
  const ssPoller = startRRwebPoller(ssDir, (batch) => {
114
+ jobs[jobId].rrwebBatches.push(batch);
111
115
  primaryStream?.emit('rrweb-batch', batch);
112
116
  });
113
117
 
@@ -123,7 +127,7 @@ function startJob({
123
127
  primaryStream?.emit('log', text);
124
128
  });
125
129
  proc.on('close', (code) => {
126
- clearInterval(ssPoller);
130
+ ssPoller.stop();
127
131
  primaryStream?.close();
128
132
  jobs[jobId].status = code === 0 ? JOB_STATUS.DONE : JOB_STATUS.ERROR;
129
133
  jobs[jobId].exitCode = code;
@@ -151,14 +155,15 @@ function startJob({
151
155
  return jobId;
152
156
  }
153
157
 
154
- // Drains and returns logs since `offset` — used by the primary's HTTP polling
155
- // loop (this node has no socket.io connection back).
156
- function pollJob(jobId, offset) {
158
+ // Drains and returns logs/rrweb batches since `offset`/`rrwebOffset` — used by
159
+ // the primary's HTTP polling loop (this node has no socket.io connection back).
160
+ function pollJob(jobId, offset, rrwebOffset = 0) {
157
161
  const job = jobs[jobId];
158
162
  if (!job) return null;
159
163
  return {
160
164
  status: job.status,
161
165
  logs: job.logs.slice(offset),
166
+ rrwebBatches: job.rrwebBatches.slice(rrwebOffset),
162
167
  exitCode: job.exitCode
163
168
  };
164
169
  }
@@ -11,8 +11,9 @@ const { isScheduledTrigger, normaliseTrigger } = require('../constants/triggers'
11
11
  const { DEFAULT_BROWSER } = require('../constants/defaults');
12
12
  const { REPORT_STATUS } = require('../constants/jobStatus');
13
13
 
14
- // Matched by string literal in backend/_scaffold/utils/plum-modules/runtime.ts
15
- // (flushRecordings) — the two runtimes don't share a module.
14
+ // Matched by string literal in backend/tests/utils/browser.ts (flushRecordings) —
15
+ // the two runtimes don't share a module, mirroring how 'image/png' is already
16
+ // duplicated between the two files.
16
17
  const RRWEB_MIME_TYPE = 'application/x-plum-rrweb+json';
17
18
  // Small, always-attached marker (independent of whether any tab actually
18
19
  // recorded events) so a scenario's worker is always recoverable for grouping,
@@ -261,21 +261,30 @@ async function dispatchAndPoll(
261
261
  }
262
262
 
263
263
  let logOffset = 0;
264
+ let rrwebOffset = 0;
264
265
  let polling = false;
265
266
  const poll = setInterval(async () => {
266
267
  if (polling) return;
267
268
  polling = true;
268
269
  try {
269
- const res = await fetch(`${runner.url}/api/execute/${jobId}?offset=${logOffset}`, {
270
- headers: bearerHeader(runner.token),
271
- signal: AbortSignal.timeout(8000)
272
- });
270
+ const res = await fetch(
271
+ `${runner.url}/api/execute/${jobId}?offset=${logOffset}&rrwebOffset=${rrwebOffset}`,
272
+ {
273
+ headers: bearerHeader(runner.token),
274
+ signal: AbortSignal.timeout(8000)
275
+ }
276
+ );
273
277
  if (!res.ok) return;
274
278
  const body = await res.json();
275
279
 
276
- if (!primaryUrl && body.logs) {
277
- onLog(body.logs);
278
- logOffset += body.logs.length;
280
+ // Skip if a socket relay is active — it already pushed these live.
281
+ if (!primaryUrl) {
282
+ if (body.logs) {
283
+ onLog(body.logs);
284
+ logOffset += body.logs.length;
285
+ }
286
+ for (const batch of body.rrwebBatches ?? []) onRRwebBatch?.(batch);
287
+ rrwebOffset += body.rrwebBatches?.length ?? 0;
279
288
  }
280
289
 
281
290
  if (body.status === JOB_STATUS.DONE || body.status === JOB_STATUS.ERROR) {
@@ -79,7 +79,7 @@ function runAttempt({
79
79
  proc.stderr.on('data', (d) => onLog(`[ERROR] ${d.toString()}`));
80
80
 
81
81
  proc.on('close', (code) => {
82
- clearInterval(ssPoller);
82
+ ssPoller.stop();
83
83
  fs.rm(ssDir, { recursive: true, force: true }, () => {});
84
84
  resolve({ code, raw: suppressSave ? readCucumberReportFile() : null });
85
85
  });
@@ -251,7 +251,7 @@ function runBuiltInAttempt({
251
251
  proc.stderr.on('data', (d) => onLog(`[ERROR] ${d.toString()}`));
252
252
 
253
253
  proc.on('close', (code) => {
254
- clearInterval(ssPoller);
254
+ ssPoller.stop();
255
255
  fs.rm(ssDir, { recursive: true, force: true }, () => {});
256
256
  activeProcs.delete(proc);
257
257
  resolve({ code, raw: suppressSave ? readCucumberReportFile() : null });
@@ -553,7 +553,7 @@ async function runDistributed(
553
553
  proc.stderr.on('data', (d) => onLog(`[ERROR] ${d.toString()}`));
554
554
 
555
555
  proc.on('close', (code) => {
556
- clearInterval(ssPoller);
556
+ ssPoller.stop();
557
557
  fs.rm(ssDir, { recursive: true, force: true }, () => {});
558
558
  activeProcs.delete(proc);
559
559
  const content =
package/bin/plum.js CHANGED
@@ -62,114 +62,6 @@ function scaffoldPluginsFile() {
62
62
  clack.log.success('plum.plugins.json created.');
63
63
  }
64
64
 
65
- // Files under tests/ that Plum's own scaffold originally wrote — `plum init`
66
- // only writes these once, so a project scaffolded before a Plum upgrade keeps
67
- // running whatever version shipped at init time (e.g. an old screenshot-based
68
- // browser.ts after Plum has moved to rrweb recording) unless something
69
- // explicitly re-syncs them.
70
- //
71
- // These are also exactly the files real projects most often rewrite entirely
72
- // with their own setup/teardown/cleanup logic — a project's browser.ts can
73
- // end up exporting things Plum's own template never did (extra page-object
74
- // helpers, auth header routing, multi-context session handling), which other
75
- // files in that project then import and depend on. A blind overwrite of a
76
- // file like that doesn't just discard "unsupported edits" — it silently
77
- // breaks every file that imports what used to be there, and can disable
78
- // cleanup logic another system relies on for a clean state. A backup makes
79
- // that recoverable, but "recoverable after your suite breaks" is still a bad
80
- // default, so this never overwrites unless explicitly told to.
81
- const INFRA_SCAFFOLD_FILES = ['utils/browser.ts', 'utils/hooks.ts'];
82
-
83
- // Nothing customer-owned ever lives here — it's the same folder run-tests.js
84
- // force-refreshes before every test run — so unlike INFRA_SCAFFOLD_FILES this
85
- // is always safe to overwrite unconditionally, no diffing or backup needed.
86
- // Re-syncing it here too (not just at test-run time) means `plum update` /
87
- // `plum sync-scaffold` alone are enough to leave a project actually working,
88
- // without requiring a test run first.
89
- const PLUM_MANAGED_DIR = 'utils/plum-modules';
90
-
91
- function syncPlumModulesDir(testsDir) {
92
- const src = path.join(scaffoldTestsPath, PLUM_MANAGED_DIR);
93
- const dest = path.join(testsDir, PLUM_MANAGED_DIR);
94
- if (!fs.existsSync(src)) return;
95
- fs.rmSync(dest, { recursive: true, force: true });
96
- fse.copySync(src, dest);
97
- }
98
-
99
- // Reports which INFRA_SCAFFOLD_FILES differ from the installed Plum version's
100
- // scaffold. With force:true, re-syncs them into the tests/ directory,
101
- // backing up whatever it overwrites — otherwise this never touches a file,
102
- // only reports on it, since diffing alone can't tell "untouched and stale"
103
- // apart from "extensively customized to depend on this exact content."
104
- // Always re-syncs PLUM_MANAGED_DIR regardless of force, since that part is
105
- // never customer-owned.
106
- function syncScaffoldInfraFiles(testsDir, { force = false } = {}) {
107
- if (!fs.existsSync(testsDir)) {
108
- clack.log.warn(`No \`tests/\` folder found at ${testsDir} — skipping scaffold sync.`);
109
- return;
110
- }
111
-
112
- syncPlumModulesDir(testsDir);
113
-
114
- let changed = 0;
115
- let stale = 0;
116
- for (const relPath of INFRA_SCAFFOLD_FILES) {
117
- const src = path.join(scaffoldTestsPath, relPath);
118
- const dest = path.join(testsDir, relPath);
119
- if (!fs.existsSync(src) || !fs.existsSync(dest)) continue;
120
-
121
- const current = fs.readFileSync(dest, 'utf8');
122
- const latest = fs.readFileSync(src, 'utf8');
123
- if (current === latest) continue;
124
-
125
- // A file that already imports plum-modules/runtime has adopted the
126
- // current pattern and will keep differing from the bare scaffold
127
- // forever once a customer adds their own code around it — that's
128
- // expected and not something to warn about every time. Only a file
129
- // that never picked up the import at all needs pointing somewhere.
130
- const alreadyWired = current.includes('plum-modules/runtime');
131
-
132
- if (!force) {
133
- stale++;
134
- if (alreadyWired) {
135
- clack.log.info(
136
- `${relPath} is customized but already wired to plum-modules/ — nothing to do.`
137
- );
138
- continue;
139
- }
140
- clack.log.warn(
141
- `${relPath} doesn't import Plum's recording wiring — reports for this project won't ` +
142
- `include session replay until it's added. This file is never auto-overwritten, so add it ` +
143
- `yourself:\n` +
144
- (relPath === 'utils/hooks.ts'
145
- ? ` Near the top of tests/${relPath}:\n` +
146
- ` import * as plum from './plum-modules/runtime';\n` +
147
- ` plum.registerHooks();\n` +
148
- ` Keep your own Before/After/BeforeStep hooks below that line — Cucumber runs every registered hook.`
149
- : ` Near the top of tests/${relPath}:\n` +
150
- ` import * as plum from './plum-modules/runtime';\n` +
151
- ` Then point your helpers at it, e.g.:\n` +
152
- ` export const page = () => plum.page();\n` +
153
- ` export const context = () => plum.context();\n` +
154
- ` export const browser = () => plum.browser();`)
155
- );
156
- continue;
157
- }
158
-
159
- const backupPath = `${dest}.bak-${Date.now()}`;
160
- fs.copyFileSync(dest, backupPath);
161
- fs.copyFileSync(src, dest);
162
- changed++;
163
- clack.log.success(
164
- `Updated ${relPath} (previous version backed up to ${path.basename(backupPath)})`
165
- );
166
- }
167
-
168
- if (changed === 0 && stale === 0) {
169
- clack.log.info('Test scaffold wiring is already up to date.');
170
- }
171
- }
172
-
173
65
  // Install user plugins listed in plum.plugins.json into the backend
174
66
  function installPlugins() {
175
67
  const pluginsPath = path.join(process.cwd(), 'plum.plugins.json');
@@ -620,15 +512,6 @@ async function serverUpdate() {
620
512
  for (const dir of getInstalls('server')) {
621
513
  if (!fs.existsSync(path.join(dir, '.plum-server.json'))) continue;
622
514
 
623
- // Runs before the restart confirm below, and regardless of its answer —
624
- // this only ever touches plum-modules/ (never customer-owned, see
625
- // syncScaffoldInfraFiles) plus a warn-only check on browser.ts/hooks.ts,
626
- // so there's nothing here that restarting the server is a prerequisite
627
- // for, or that declining the restart should skip.
628
- if (fs.existsSync(path.join(dir, 'tests'))) {
629
- syncScaffoldInfraFiles(path.join(dir, 'tests'));
630
- }
631
-
632
515
  // This registry is global to the machine, not scoped to the directory
633
516
  // `plum update` was run from — an unrelated project on the same machine
634
517
  // as a registered server would otherwise silently boot that server's
@@ -657,12 +540,6 @@ async function serverUpdate() {
657
540
  const nodeCfg = loadNodeConfig(dir);
658
541
  if (!nodeCfg.id) continue;
659
542
 
660
- // Same reasoning as the server loop above: runs regardless of whether
661
- // the restart below gets confirmed.
662
- if (fs.existsSync(path.join(dir, 'tests'))) {
663
- syncScaffoldInfraFiles(path.join(dir, 'tests'));
664
- }
665
-
666
543
  // This registry spans the whole machine, not just the directory
667
544
  // `plum update` was run from.
668
545
  if (interactiveAllowed()) {
@@ -1244,14 +1121,6 @@ switch (command) {
1244
1121
  await serverUpdate();
1245
1122
  break;
1246
1123
 
1247
- case 'sync-scaffold': {
1248
- clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Sync Test Scaffold ')));
1249
- const force = anyFlags(process.argv.slice(3), ['--force']);
1250
- syncScaffoldInfraFiles(userTestsPath, { force });
1251
- clack.outro(pc.green('Done.'));
1252
- break;
1253
- }
1254
-
1255
1124
  case 'run-test': {
1256
1125
  const runHelpArgs = process.argv.slice(3);
1257
1126
  if (anyFlags(runHelpArgs, ['--help', '-h'])) {
@@ -1458,13 +1327,7 @@ switch (command) {
1458
1327
  console.log(' server stop Stop the server (data preserved)');
1459
1328
  console.log(' server reconfig Re-enter server settings without starting');
1460
1329
  console.log(
1461
- ' update Update Plum, restart whichever is running (server/node), and check tests/ wiring for updates'
1462
- );
1463
- console.log(
1464
- ' sync-scaffold Check browser.ts/hooks.ts in tests/ against the installed Plum version'
1465
- );
1466
- console.log(
1467
- ' --force Overwrite files that differ (previous version backed up first)'
1330
+ ' update Update Plum and restart whichever is running (server/node)'
1468
1331
  );
1469
1332
  console.log(' node start Start a runner node (interactive), then open runner menu');
1470
1333
  console.log(' --primary <url> Primary Plum server to auto-register with');
@@ -24,9 +24,9 @@
24
24
  let resizeObserver;
25
25
 
26
26
  // rrweb-player renders at the recording's native resolution — scale-and-crop
27
- // it to fill the stage (object-fit: cover) instead of letterboxing. Re-run on
28
- // every stage resize, since the panel's own layout (tab strips appearing,
29
- // window resize) can still shift its size after the player is built.
27
+ // it to fill the stage (object-fit: cover); letterboxing left visible bars
28
+ // when the aspect ratios didn't match. Re-run on every stage resize, since
29
+ // the panel's own layout can still shift its size after the player is built.
30
30
  function updateScale() {
31
31
  if (!nativeWidth || !nativeHeight) return;
32
32
  const rect = stage.getBoundingClientRect();
@@ -28,6 +28,9 @@
28
28
  const MIN_PLAYER_WIDTH = 480;
29
29
  const MIN_PLAYER_HEIGHT = 320;
30
30
  const CONTROLLER_HEIGHT = 80;
31
+ // Covers rrweb's 50ms 'finish' scheduling delay after a paused seek, so it's
32
+ // never misread as reaching a natural finish.
33
+ const FINISH_SUPPRESS_MS = 150;
31
34
  // Player would otherwise exactly fill .player-stage, leaving the button row
32
35
  // flush against its edge — shrinks it so centering leaves a margin.
33
36
  const STAGE_BREATHING_ROOM = 24;
@@ -116,8 +119,16 @@
116
119
  // — e.g. after toggling Inspect there may be nothing loaded to render.
117
120
  // Rebuild instead so the right FullSnapshot gets loaded again.
118
121
  if (targetIdx === activeSegmentIndex && ts >= mountedFirst) {
122
+ if (!autoplay) suppressFinishUntil = Date.now() + FINISH_SUPPRESS_MS;
119
123
  player?.goto(ts - mountedFirst, autoplay);
120
124
  if (stepIndexOverride !== undefined) currentStepIndex = stepIndexOverride;
125
+ // goto() can cross a mid-stream FullSnapshot (e.g. a tab navigating off
126
+ // about:blank) and silently reset the iframe document — re-attach.
127
+ if (inspecting) {
128
+ teardownInspectListeners();
129
+ setupInspectListeners();
130
+ startInspectWatch();
131
+ }
121
132
  } else {
122
133
  activeSegmentIndex = targetIdx;
123
134
  buildPlayer({
@@ -134,7 +145,14 @@
134
145
  if (stepTimestamps[i] === undefined || !player) return;
135
146
  currentStepIndex = i;
136
147
  // Jump to the next marker — step i's own marker fires before its actions run.
137
- const nextTs = stepTimestamps[i + 1] ?? segments[segments.length - 1]?.to;
148
+ // The last step has none: use the segment's real last event, not its `to`
149
+ // boundary (can sit past it) — landing at/past the real end reads as a
150
+ // natural finish instead of a paused seek (see suppressFinishUntil).
151
+ let nextTs = stepTimestamps[i + 1];
152
+ if (nextTs === undefined) {
153
+ const { first, span } = segmentEventBounds(segments[segments.length - 1]);
154
+ nextTs = first + Math.max(0, span - 1);
155
+ }
138
156
  if (nextTs === undefined) return;
139
157
  seekToAbsolute(nextTs, false, i);
140
158
  }
@@ -184,6 +202,14 @@
184
202
  doc.addEventListener('click', onClick, true);
185
203
  doc.addEventListener('mouseleave', onLeave);
186
204
  doc.addEventListener('keydown', onKeydown);
205
+ // rrweb can rebuild the document in place (document.open()/write()) without
206
+ // the contentDocument reference changing — watchInspectDoc's reference
207
+ // check alone would miss that a fresh documentElement wiped this marker.
208
+ try {
209
+ doc.documentElement.dataset.plumInspectWired = '1';
210
+ } catch {
211
+ // cross-origin/detached doc — inspect wiring is best-effort
212
+ }
187
213
 
188
214
  cleanupInspect = () => {
189
215
  doc.removeEventListener('mousemove', onMove);
@@ -220,7 +246,8 @@
220
246
  return;
221
247
  }
222
248
  const doc = currentReplayer()?.iframe?.contentDocument;
223
- if (doc && doc !== inspectAttachedDoc) {
249
+ const stillWired = doc?.documentElement?.dataset.plumInspectWired === '1';
250
+ if (doc && (doc !== inspectAttachedDoc || !stillWired)) {
224
251
  teardownInspectListeners();
225
252
  setupInspectListeners();
226
253
  }
@@ -277,6 +304,8 @@
277
304
  // during a paused seek's sync catch-up. Only real autoplay should trigger
278
305
  // the auto-advance-to-next-tab below.
279
306
  let awaitingNaturalFinish = false;
307
+ // Set right before any deliberate paused seek — see FINISH_SUPPRESS_MS.
308
+ let suppressFinishUntil = 0;
280
309
 
281
310
  // rrweb's 50ms finish timeout isn't cancelled by destroying the replayer —
282
311
  // a short segment can be torn down before its own stale finish fires,
@@ -304,6 +333,7 @@
304
333
 
305
334
  replayer.on('finish', () => {
306
335
  if (buildGeneration !== myGeneration) return;
336
+ if (Date.now() < suppressFinishUntil) return;
307
337
  if (!awaitingNaturalFinish) return;
308
338
  if (activeSegmentIndex < segments.length - 1) {
309
339
  const speed = replayer.config.speed;
@@ -462,12 +492,18 @@
462
492
  player.play();
463
493
  } else if (resumeState.finished) {
464
494
  player.setSpeed(resumeState.speed);
495
+ // A fresh Player starts at its first frame — without this explicit seek,
496
+ // rebuilding here (e.g. toggling Inspect after a natural finish) would
497
+ // show a blank first frame instead of staying on the last one.
498
+ suppressFinishUntil = Date.now() + FINISH_SUPPRESS_MS;
499
+ player.goto(timeOffset, false);
465
500
  finished = true;
466
501
  livePosition = overallTo;
467
502
  // Restart button moved under this rebuild — recompute its position.
468
503
  requestAnimationFrame(() => positionRestartButton(playPauseButton()));
469
504
  } else {
470
505
  player.setSpeed(resumeState.speed);
506
+ if (resumeState.paused) suppressFinishUntil = Date.now() + FINISH_SUPPRESS_MS;
471
507
  player.goto(timeOffset, !resumeState.paused);
472
508
  }
473
509
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plum-e2e",
3
- "version": "2.9.4",
3
+ "version": "2.9.7",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/silverlunah/plum.git"
@@ -1,24 +0,0 @@
1
- # Do not edit this folder
2
-
3
- Everything under `plum-modules/` is regenerated from the installed Plum version before every test run — any change you make here is silently overwritten the next time you run `plum run-test`, `plum node start`, or trigger a run from the web UI.
4
-
5
- This is what gives you Plum's session recording (rrweb) and reporting hooks. Use it from your own `tests/utils/browser.ts` and `hooks.ts`:
6
-
7
- ```ts
8
- import * as plum from './plum-modules/runtime';
9
-
10
- export const page = () => plum.page();
11
- export const context = () => plum.context();
12
- export const browser = () => plum.browser();
13
- ```
14
-
15
- ```ts
16
- import * as plum from './plum-modules/runtime';
17
-
18
- plum.registerHooks();
19
-
20
- // Add your own Before/After/BeforeStep hooks below — Cucumber runs every
21
- // registered hook, so yours run alongside Plum's.
22
- ```
23
-
24
- If you need something from here that isn't exported, don't copy the file — ask, since it's meant to be extended, not forked.
@@ -1,308 +0,0 @@
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
-
18
- // DO NOT EDIT — see README.md in this directory. Plum overwrites this whole
19
- // folder before every run, so any change here is silently discarded.
20
-
21
- import { chromium, firefox, webkit, Browser, BrowserContext, Page } from 'playwright';
22
- import { Before, After, BeforeStep, ITestCaseHookParameter } from '@cucumber/cucumber';
23
- import * as fs from 'fs';
24
- import * as path from 'path';
25
- import * as zlib from 'zlib';
26
- import dotenv from 'dotenv';
27
-
28
- dotenv.config();
29
-
30
- // Must match the mime type Plum's server expects — do not change.
31
- const RRWEB_MIME_TYPE = 'application/x-plum-rrweb+json';
32
- // Always attached, even for a scenario with no recorded events, so the
33
- // worker that ran it is still recoverable for grouping.
34
- const WORKER_META_MIME_TYPE = 'application/x-plum-worker+json';
35
- // @rrweb/record's package.json only exports its main entry ("."), so a deep
36
- // require.resolve() of the UMD bundle is blocked by Node's exports map — resolve
37
- // the (exported) main entry instead and locate the sibling file on disk.
38
- const RECORD_BUNDLE_PATH = path.join(
39
- path.dirname(require.resolve('@rrweb/record')),
40
- 'record.umd.min.cjs'
41
- );
42
-
43
- interface TabRecording {
44
- tabId: string;
45
- tabIndex: number;
46
- events: unknown[];
47
- openedAt: number;
48
- closedAt: number | null;
49
- liveFlushedCount: number;
50
- }
51
-
52
- let _browser: Browser;
53
- let _context: BrowserContext;
54
- let _page: Page;
55
- let _liveRRwebCounter = 0;
56
- let _liveRRwebTimer: ReturnType<typeof setInterval> | null = null;
57
- let _tabs: Map<Page, TabRecording> = new Map();
58
- let _tabCounter = 0;
59
- let _workerId = 1;
60
-
61
- export const page = (): Page => _page;
62
- export const context = (): BrowserContext => _context;
63
- export const browser = (): Browser => _browser;
64
-
65
- function tabIdForIndex(index: number): string {
66
- return index === 0 ? 'main' : `tab-${index + 1}`;
67
- }
68
-
69
- // A static page (nothing left to interact with) can go a long time between
70
- // rrweb events, or emit none at all after its initial load — its own event
71
- // timestamps are a poor proxy for how long it stayed relevant. Real
72
- // open/close times let the replay UI line multiple tabs up on one timeline
73
- // without guessing from event gaps.
74
- function attachRecorder(pg: Page): void {
75
- const tabIndex = _tabCounter++;
76
- const recording: TabRecording = {
77
- tabId: tabIdForIndex(tabIndex),
78
- tabIndex,
79
- events: [],
80
- openedAt: Date.now(),
81
- closedAt: null,
82
- liveFlushedCount: 0
83
- };
84
- _tabs.set(pg, recording);
85
- pg.on('close', () => {
86
- recording.closedAt = Date.now();
87
- });
88
- }
89
-
90
- // contextOptions is passed straight to Browser.newContext() — use it for
91
- // project-specific needs (viewport, permissions, etc.) rather than creating
92
- // a second context yourself, so rrweb recording (wired below) still covers it.
93
- export async function setup(
94
- contextOptions: Parameters<Browser['newContext']>[0] = {}
95
- ): Promise<void> {
96
- const isHeadless = process.env.IS_HEADLESS?.toLowerCase() !== 'false';
97
- const browserName = (process.env.BROWSER || 'chromium').toLowerCase();
98
- const browserType =
99
- browserName === 'firefox' ? firefox : browserName === 'webkit' ? webkit : chromium;
100
- _browser = await browserType.launch({ headless: isHeadless });
101
- _context = await _browser.newContext(contextOptions);
102
-
103
- _tabs = new Map();
104
- _tabCounter = 0;
105
- // Cucumber forks one OS process per --parallel worker and injects this env
106
- // var into each — 0-indexed, so display/report as 1-based like the rest of
107
- // the worker-count UI.
108
- const parsedWorkerId = parseInt(process.env.CUCUMBER_WORKER_ID ?? '', 10);
109
- _workerId = Number.isFinite(parsedWorkerId) ? parsedWorkerId + 1 : 1;
110
-
111
- // Context-level exposeBinding/addInitScript apply to every page in the
112
- // context automatically — current and future (popups, target=_blank tabs) —
113
- // so recording setup never races a new tab's first navigation.
114
- await _context.exposeBinding('__plumEmitRRwebEvent', (source, eventJson: string) => {
115
- const recording = source.page && _tabs.get(source.page);
116
- if (!recording) return;
117
- try {
118
- recording.events.push(JSON.parse(eventJson));
119
- } catch {
120
- // malformed event — drop it, recording is best-effort
121
- }
122
- });
123
- await _context.addInitScript({ path: RECORD_BUNDLE_PATH });
124
- await _context.addInitScript(() => {
125
- // addInitScript runs in every frame, including hidden ad/tracking iframes.
126
- // Recordings are tracked per-Page, so an unguarded sub-frame session would
127
- // corrupt the tab's event stream with bogus 0x0 "about:blank" entries.
128
- // @ts-ignore
129
- if (window.self !== window.top) return;
130
- // @ts-ignore
131
- if (window.rrwebRecord) {
132
- // @ts-ignore
133
- window.rrwebRecord.record({
134
- emit: (event: unknown) => {
135
- // @ts-ignore — exposed by BrowserContext.exposeBinding above
136
- window.__plumEmitRRwebEvent(JSON.stringify(event));
137
- }
138
- });
139
- }
140
- });
141
-
142
- _context.on('page', attachRecorder);
143
- _page = await _context.newPage();
144
-
145
- // Only when someone's actually watching live — a scheduled/background run
146
- // with no viewer shouldn't pay for this.
147
- if (process.env.PLUM_SS_DIR) {
148
- _liveRRwebTimer = setInterval(flushLiveRRwebEvents, 500);
149
- }
150
- }
151
-
152
- // Sends only what's newly arrived since the last tick, per tab, so the live
153
- // viewer gets a steady trickle instead of the full buffer growing unbounded.
154
- function flushLiveRRwebEvents(): void {
155
- const ssDir = process.env.PLUM_SS_DIR;
156
- if (!ssDir) return;
157
- for (const recording of _tabs.values()) {
158
- const newEvents = recording.events.slice(recording.liveFlushedCount);
159
- if (newEvents.length === 0) continue;
160
- recording.liveFlushedCount = recording.events.length;
161
- try {
162
- const seq = `${String(Date.now()).padStart(16, '0')}-${String(++_liveRRwebCounter).padStart(4, '0')}`;
163
- fs.writeFileSync(
164
- path.join(ssDir, `${seq}.rrweb.json`),
165
- JSON.stringify({
166
- workerId: _workerId,
167
- tabId: recording.tabId,
168
- tabIndex: recording.tabIndex,
169
- events: newEvents
170
- })
171
- );
172
- } catch {
173
- // best-effort — live streaming shouldn't affect the recording itself
174
- }
175
- }
176
- }
177
-
178
- /**
179
- * Injects a labeled rrweb custom event at the current recording timestamp so
180
- * the replay UI can show which step was running at any point in the timeline.
181
- */
182
- export async function markStepStart(stepName: string): Promise<void> {
183
- if (!_page) return;
184
- try {
185
- await _page.evaluate((name) => {
186
- // @ts-ignore — rrwebRecord is injected by the record.umd.min.cjs bundle
187
- if (window.rrwebRecord?.record?.addCustomEvent) {
188
- // @ts-ignore
189
- window.rrwebRecord.record.addCustomEvent('step', { name });
190
- }
191
- }, stepName);
192
- } catch {
193
- // best-effort — a missing marker just means the replay UI won't show a
194
- // step label at that point, it doesn't affect the recording itself
195
- }
196
- }
197
-
198
- /**
199
- * Flushes every tab's buffered rrweb events (one per opened tab/popup) as a
200
- * gzip-compressed Cucumber attachment, tagged with the mime type Plum's
201
- * server looks for.
202
- */
203
- export async function flushRecordings(
204
- attach: (data: Buffer, mime: string) => Promise<void>
205
- ): Promise<void> {
206
- if (_liveRRwebTimer) {
207
- clearInterval(_liveRRwebTimer);
208
- _liveRRwebTimer = null;
209
- }
210
- // One last live flush so the stream doesn't miss whatever happened between
211
- // the final tick and scenario end.
212
- flushLiveRRwebEvents();
213
-
214
- try {
215
- await attach(
216
- Buffer.from(JSON.stringify({ workerId: _workerId }), 'utf8'),
217
- WORKER_META_MIME_TYPE
218
- );
219
- } catch {
220
- // best-effort — a missing worker marker just falls back to workerId 1
221
- }
222
-
223
- const flushedAt = Date.now();
224
- for (const recording of _tabs.values()) {
225
- if (recording.events.length === 0) continue;
226
- try {
227
- const payload = JSON.stringify({
228
- workerId: _workerId,
229
- tabId: recording.tabId,
230
- tabIndex: recording.tabIndex,
231
- events: recording.events,
232
- openedAt: recording.openedAt,
233
- // A tab still open when the scenario ends (typically the main tab)
234
- // stayed relevant through to the flush, not just its last DOM event.
235
- closedAt: recording.closedAt ?? flushedAt
236
- });
237
- const gz = zlib.gzipSync(Buffer.from(payload, 'utf8'));
238
- await attach(gz, RRWEB_MIME_TYPE);
239
- } catch {
240
- // a failed recording flush shouldn't fail the scenario
241
- }
242
- }
243
- }
244
-
245
- export async function teardown(): Promise<void> {
246
- await _browser?.close();
247
- }
248
-
249
- // Pickle steps carry no keyword (Cucumber normalizes Given/When/Then/And/But
250
- // away during Gherkin → Pickle compilation) — recover it by walking the
251
- // gherkinDocument for the AST node the pickle step was compiled from.
252
- function resolveStepKeyword(gherkinDocument: any, pickleStep: any): string {
253
- const astNodeId = pickleStep?.astNodeIds?.[0];
254
- if (!astNodeId) return '';
255
- const steps: any[] = [];
256
- for (const child of gherkinDocument?.feature?.children ?? []) {
257
- if (child.background) steps.push(...child.background.steps);
258
- if (child.scenario) steps.push(...child.scenario.steps);
259
- for (const ruleChild of child.rule?.children ?? []) {
260
- if (ruleChild.background) steps.push(...ruleChild.background.steps);
261
- if (ruleChild.scenario) steps.push(...ruleChild.scenario.steps);
262
- }
263
- }
264
- return steps.find((s) => s.id === astNodeId)?.keyword?.trim() ?? '';
265
- }
266
-
267
- /**
268
- * "Given/When/Then <step text>" for a BeforeStep's (pickleStep, gherkinDocument)
269
- * pair — pass straight to markStepStart(). Exported for a project that writes
270
- * its own BeforeStep instead of using registerHooks(), so it doesn't need to
271
- * duplicate the keyword-recovery logic to get the same replay labels.
272
- */
273
- export function stepLabel(gherkinDocument: any, pickleStep: any): string {
274
- const keyword = resolveStepKeyword(gherkinDocument, pickleStep);
275
- const text = pickleStep?.text ?? '';
276
- return keyword ? `${keyword} ${text}` : text;
277
- }
278
-
279
- /**
280
- * Registers Plum's own Before/BeforeStep/After hooks. Call once from your
281
- * own tests/utils/hooks.ts — Cucumber supports multiple Before/After hooks,
282
- * so your own hooks can still be added alongside this. If you need custom
283
- * context options (viewport, permissions, etc.) or to run something between
284
- * browser launch and the first page load, call setup()/markStepStart()/
285
- * flushRecordings()/teardown() from your own hooks instead of this.
286
- */
287
- export function registerHooks(): void {
288
- Before(async ({ pickle }: ITestCaseHookParameter) => {
289
- const tags = pickle.tags.map((t) => t.name).join(' ');
290
- console.log(`\n▶ ${pickle.name}${tags ? ` ${tags}` : ''}`);
291
- await setup();
292
- });
293
-
294
- BeforeStep(async function ({
295
- pickleStep,
296
- gherkinDocument
297
- }: {
298
- pickleStep: any;
299
- gherkinDocument: any;
300
- }) {
301
- await markStepStart(stepLabel(gherkinDocument, pickleStep));
302
- });
303
-
304
- After(async function () {
305
- await flushRecordings(this.attach.bind(this));
306
- await teardown();
307
- });
308
- }