plum-e2e 2.9.0 → 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.
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,222 +15,12 @@
15
15
  * along with Plum. If not, see https://www.gnu.org/licenses/.
16
16
  */
17
17
 
18
- // Wires up Plum's session recording removing or reordering code here can silently break report replay.
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';
19
22
 
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';
23
+ const runtime = require(process.env.PLUM_RUNTIME_PATH as string);
24
24
 
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
- }
25
+ export const page = (): Page => runtime.page();
26
+ export const context = (): BrowserContext => runtime.context();
@@ -15,53 +15,9 @@
15
15
  * along with Plum. If not, see https://www.gnu.org/licenses/.
16
16
  */
17
17
 
18
- // Wires up Plum's session recording removing or reordering code here can silently break report replay.
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();
19
21
 
20
- import { Before, After, BeforeStep, ITestCaseHookParameter } from '@cucumber/cucumber';
21
- import { setup, teardown, flushRecordings, markStepStart } from './browser';
22
- import dotenv from 'dotenv';
23
-
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
- });
22
+ // Add your own custom Before/After/BeforeStep hooks below Cucumber runs
23
+ // every registered hook, so these run alongside Plum's own.
@@ -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) {
@@ -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
+ };
package/bin/plum.js CHANGED
@@ -62,6 +62,48 @@ function scaffoldPluginsFile() {
62
62
  clack.log.success('plum.plugins.json created.');
63
63
  }
64
64
 
65
+ // Files under tests/ that are Plum's own wiring rather than customer content —
66
+ // `plum init` only writes these once, so a project scaffolded before a Plum
67
+ // upgrade keeps running whatever version shipped at init time (e.g. an old
68
+ // screenshot-based browser.ts after Plum has moved to rrweb recording) unless
69
+ // something explicitly re-syncs them. Never touches customer-owned files
70
+ // (features/, pages/, step_definitions/, utils/constants.ts, utils/utils.ts).
71
+ const INFRA_SCAFFOLD_FILES = ['utils/browser.ts', 'utils/hooks.ts'];
72
+
73
+ // Re-syncs INFRA_SCAFFOLD_FILES from the installed Plum version's scaffold
74
+ // into an existing tests/ directory, backing up anything it overwrites so a
75
+ // customer's own edits to these files (unsupported, but possible) aren't
76
+ // silently lost.
77
+ function syncScaffoldInfraFiles(testsDir) {
78
+ if (!fs.existsSync(testsDir)) {
79
+ clack.log.warn(`No \`tests/\` folder found at ${testsDir} — skipping scaffold sync.`);
80
+ return;
81
+ }
82
+
83
+ let updated = 0;
84
+ for (const relPath of INFRA_SCAFFOLD_FILES) {
85
+ const src = path.join(scaffoldTestsPath, relPath);
86
+ const dest = path.join(testsDir, relPath);
87
+ if (!fs.existsSync(src) || !fs.existsSync(dest)) continue;
88
+
89
+ const current = fs.readFileSync(dest, 'utf8');
90
+ const latest = fs.readFileSync(src, 'utf8');
91
+ if (current === latest) continue;
92
+
93
+ const backupPath = `${dest}.bak-${Date.now()}`;
94
+ fs.copyFileSync(dest, backupPath);
95
+ fs.copyFileSync(src, dest);
96
+ updated++;
97
+ clack.log.success(
98
+ `Updated ${relPath} (previous version backed up to ${path.basename(backupPath)})`
99
+ );
100
+ }
101
+
102
+ if (updated === 0) {
103
+ clack.log.info('Test scaffold wiring is already up to date.');
104
+ }
105
+ }
106
+
65
107
  // Install user plugins listed in plum.plugins.json into the backend
66
108
  function installPlugins() {
67
109
  const pluginsPath = path.join(process.cwd(), 'plum.plugins.json');
@@ -511,6 +553,9 @@ async function serverUpdate() {
511
553
  // logic no matter how new the just-installed files on disk actually are.
512
554
  for (const dir of getInstalls('server')) {
513
555
  if (!fs.existsSync(path.join(dir, '.plum-server.json'))) continue;
556
+ if (fs.existsSync(path.join(dir, 'tests'))) {
557
+ syncScaffoldInfraFiles(path.join(dir, 'tests'));
558
+ }
514
559
  clack.log.step(`Rebuilding server at ${dir}…`);
515
560
  try {
516
561
  execSync('plum server restart', { stdio: 'inherit', cwd: dir });
@@ -523,6 +568,9 @@ async function serverUpdate() {
523
568
  for (const dir of getInstalls('node')) {
524
569
  const nodeCfg = loadNodeConfig(dir);
525
570
  if (!nodeCfg.id) continue;
571
+ if (fs.existsSync(path.join(dir, 'tests'))) {
572
+ syncScaffoldInfraFiles(path.join(dir, 'tests'));
573
+ }
526
574
  // Always attempt the restart rather than gating on the local PID
527
575
  // registry: that registry goes stale (manager restarts, pre-existing
528
576
  // installs from before this tracking existed, etc.), and skipping the
@@ -1093,6 +1141,12 @@ switch (command) {
1093
1141
  await serverUpdate();
1094
1142
  break;
1095
1143
 
1144
+ case 'sync-scaffold':
1145
+ clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Sync Test Scaffold ')));
1146
+ syncScaffoldInfraFiles(userTestsPath);
1147
+ clack.outro(pc.green('Done.'));
1148
+ break;
1149
+
1096
1150
  case 'run-test': {
1097
1151
  const runHelpArgs = process.argv.slice(3);
1098
1152
  if (anyFlags(runHelpArgs, ['--help', '-h'])) {
@@ -1299,7 +1353,10 @@ switch (command) {
1299
1353
  console.log(' server stop Stop the server (data preserved)');
1300
1354
  console.log(' server reconfig Re-enter server settings without starting');
1301
1355
  console.log(
1302
- ' update Update Plum and restart whichever is running (server/node)'
1356
+ ' update Update Plum, restart whichever is running (server/node), and re-sync tests/ wiring'
1357
+ );
1358
+ console.log(
1359
+ ' sync-scaffold Re-sync browser.ts/hooks.ts in tests/ from the installed Plum version'
1303
1360
  );
1304
1361
  console.log(' node start Start a runner node (interactive), then open runner menu');
1305
1362
  console.log(' --primary <url> Primary Plum server to auto-register with');
@@ -58,41 +58,42 @@ declare module '$env/static/private' {
58
58
  export const VIKUNJA_API_KEY: string;
59
59
  export const USER: string;
60
60
  export const COMMAND_MODE: string;
61
- export const npm_config_globalconfig: string;
62
61
  export const OUTLINE_BASE_URL: string;
62
+ export const npm_config_globalconfig: string;
63
63
  export const CLAUDE_CODE_SSE_PORT: string;
64
64
  export const SSH_AUTH_SOCK: string;
65
65
  export const VSCODE_PROFILE_INITIALIZED: string;
66
66
  export const __CF_USER_TEXT_ENCODING: string;
67
67
  export const npm_execpath: string;
68
68
  export const PATH: string;
69
- export const npm_package_json: string;
69
+ export const npm_package_bin_plum: string;
70
70
  export const npm_config_engine_strict: string;
71
71
  export const _: string;
72
- export const npm_config_userconfig: string;
73
- export const npm_config_init_module: string;
72
+ export const npm_package_json: string;
74
73
  export const USER_ZDOTDIR: string;
75
74
  export const __CFBundleIdentifier: string;
76
- export const npm_command: string;
75
+ export const npm_config_init_module: string;
76
+ export const npm_config_userconfig: string;
77
77
  export const PWD: string;
78
- export const npm_lifecycle_event: string;
79
- export const EDITOR: string;
78
+ export const npm_command: string;
80
79
  export const OUTLINE_API_KEY: string;
81
- export const npm_package_name: string;
80
+ export const EDITOR: string;
81
+ export const npm_lifecycle_event: string;
82
82
  export const LANG: string;
83
- export const npm_config_npm_version: string;
83
+ export const npm_package_name: string;
84
84
  export const VSCODE_GIT_ASKPASS_EXTRA_ARGS: string;
85
85
  export const XPC_FLAGS: string;
86
+ export const npm_config_npm_version: string;
86
87
  export const npm_config_node_gyp: string;
87
- export const npm_package_version: string;
88
88
  export const XPC_SERVICE_NAME: string;
89
+ export const npm_package_version: string;
89
90
  export const VSCODE_INJECTION: string;
90
91
  export const SHLVL: string;
91
92
  export const HOME: string;
92
93
  export const VSCODE_GIT_ASKPASS_MAIN: string;
93
94
  export const CLAUDE_CODE_EXECPATH: string;
94
- export const npm_config_cache: string;
95
95
  export const LOGNAME: string;
96
+ export const npm_config_cache: string;
96
97
  export const npm_lifecycle_script: string;
97
98
  export const VSCODE_GIT_IPC_HANDLE: string;
98
99
  export const COREPACK_ENABLE_AUTO_PIN: string;
@@ -103,10 +104,9 @@ declare module '$env/static/private' {
103
104
  export const OSLogRateLimit: string;
104
105
  export const CLAUDECODE: string;
105
106
  export const CLAUDE_CODE_MESSAGING_SOCKET: string;
106
- export const npm_node_execpath: string;
107
- export const npm_config_prefix: string;
108
107
  export const COLORTERM: string;
109
- export const NODE_ENV: string;
108
+ export const npm_config_prefix: string;
109
+ export const npm_node_execpath: string;
110
110
  }
111
111
 
112
112
  /**
@@ -165,41 +165,42 @@ declare module '$env/dynamic/private' {
165
165
  VIKUNJA_API_KEY: string;
166
166
  USER: string;
167
167
  COMMAND_MODE: string;
168
- npm_config_globalconfig: string;
169
168
  OUTLINE_BASE_URL: string;
169
+ npm_config_globalconfig: string;
170
170
  CLAUDE_CODE_SSE_PORT: string;
171
171
  SSH_AUTH_SOCK: string;
172
172
  VSCODE_PROFILE_INITIALIZED: string;
173
173
  __CF_USER_TEXT_ENCODING: string;
174
174
  npm_execpath: string;
175
175
  PATH: string;
176
- npm_package_json: string;
176
+ npm_package_bin_plum: string;
177
177
  npm_config_engine_strict: string;
178
178
  _: string;
179
- npm_config_userconfig: string;
180
- npm_config_init_module: string;
179
+ npm_package_json: string;
181
180
  USER_ZDOTDIR: string;
182
181
  __CFBundleIdentifier: string;
183
- npm_command: string;
182
+ npm_config_init_module: string;
183
+ npm_config_userconfig: string;
184
184
  PWD: string;
185
- npm_lifecycle_event: string;
186
- EDITOR: string;
185
+ npm_command: string;
187
186
  OUTLINE_API_KEY: string;
188
- npm_package_name: string;
187
+ EDITOR: string;
188
+ npm_lifecycle_event: string;
189
189
  LANG: string;
190
- npm_config_npm_version: string;
190
+ npm_package_name: string;
191
191
  VSCODE_GIT_ASKPASS_EXTRA_ARGS: string;
192
192
  XPC_FLAGS: string;
193
+ npm_config_npm_version: string;
193
194
  npm_config_node_gyp: string;
194
- npm_package_version: string;
195
195
  XPC_SERVICE_NAME: string;
196
+ npm_package_version: string;
196
197
  VSCODE_INJECTION: string;
197
198
  SHLVL: string;
198
199
  HOME: string;
199
200
  VSCODE_GIT_ASKPASS_MAIN: string;
200
201
  CLAUDE_CODE_EXECPATH: string;
201
- npm_config_cache: string;
202
202
  LOGNAME: string;
203
+ npm_config_cache: string;
203
204
  npm_lifecycle_script: string;
204
205
  VSCODE_GIT_IPC_HANDLE: string;
205
206
  COREPACK_ENABLE_AUTO_PIN: string;
@@ -210,10 +211,9 @@ declare module '$env/dynamic/private' {
210
211
  OSLogRateLimit: string;
211
212
  CLAUDECODE: string;
212
213
  CLAUDE_CODE_MESSAGING_SOCKET: string;
213
- npm_node_execpath: string;
214
- npm_config_prefix: string;
215
214
  COLORTERM: string;
216
- NODE_ENV: string;
215
+ npm_config_prefix: string;
216
+ npm_node_execpath: string;
217
217
  [key: `PUBLIC_${string}`]: undefined;
218
218
  [key: `${string}`]: string | undefined;
219
219
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plum-e2e",
3
- "version": "2.9.0",
3
+ "version": "2.9.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/silverlunah/plum.git"