plum-e2e 2.9.3 → 2.9.6
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 +19 -27
- package/backend/_scaffold/utils/browser.ts +224 -6
- package/backend/_scaffold/utils/hooks.ts +54 -4
- package/backend/config/scripts/run-tests.js +0 -13
- package/backend/services/reportService.js +3 -2
- package/bin/plum.js +1 -138
- package/package.json +1 -1
- package/backend/_scaffold/utils/plum-modules/README.md +0 -24
- package/backend/_scaffold/utils/plum-modules/runtime.ts +0 -290
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
|
|
91
|
-
| `plum
|
|
92
|
-
| `plum node
|
|
93
|
-
| `plum node
|
|
94
|
-
| `plum
|
|
95
|
-
| `plum
|
|
96
|
-
| `plum run-test`
|
|
97
|
-
| `plum run-test
|
|
98
|
-
| `plum run-test --
|
|
99
|
-
| `plum
|
|
100
|
-
| `plum
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
18
|
+
// Wires up Plum's session recording — removing or reordering code here can silently break report replay.
|
|
19
19
|
|
|
20
|
-
|
|
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
|
-
|
|
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
|
+
});
|
|
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,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/
|
|
15
|
-
//
|
|
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,
|
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
|
|
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');
|
package/package.json
CHANGED
|
@@ -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,290 +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
|
-
export async function setup(): Promise<void> {
|
|
91
|
-
const isHeadless = process.env.IS_HEADLESS?.toLowerCase() !== 'false';
|
|
92
|
-
const browserName = (process.env.BROWSER || 'chromium').toLowerCase();
|
|
93
|
-
const browserType =
|
|
94
|
-
browserName === 'firefox' ? firefox : browserName === 'webkit' ? webkit : chromium;
|
|
95
|
-
_browser = await browserType.launch({ headless: isHeadless });
|
|
96
|
-
_context = await _browser.newContext();
|
|
97
|
-
|
|
98
|
-
_tabs = new Map();
|
|
99
|
-
_tabCounter = 0;
|
|
100
|
-
// Cucumber forks one OS process per --parallel worker and injects this env
|
|
101
|
-
// var into each — 0-indexed, so display/report as 1-based like the rest of
|
|
102
|
-
// the worker-count UI.
|
|
103
|
-
const parsedWorkerId = parseInt(process.env.CUCUMBER_WORKER_ID ?? '', 10);
|
|
104
|
-
_workerId = Number.isFinite(parsedWorkerId) ? parsedWorkerId + 1 : 1;
|
|
105
|
-
|
|
106
|
-
// Context-level exposeBinding/addInitScript apply to every page in the
|
|
107
|
-
// context automatically — current and future (popups, target=_blank tabs) —
|
|
108
|
-
// so recording setup never races a new tab's first navigation.
|
|
109
|
-
await _context.exposeBinding('__plumEmitRRwebEvent', (source, eventJson: string) => {
|
|
110
|
-
const recording = source.page && _tabs.get(source.page);
|
|
111
|
-
if (!recording) return;
|
|
112
|
-
try {
|
|
113
|
-
recording.events.push(JSON.parse(eventJson));
|
|
114
|
-
} catch {
|
|
115
|
-
// malformed event — drop it, recording is best-effort
|
|
116
|
-
}
|
|
117
|
-
});
|
|
118
|
-
await _context.addInitScript({ path: RECORD_BUNDLE_PATH });
|
|
119
|
-
await _context.addInitScript(() => {
|
|
120
|
-
// addInitScript runs in every frame, including hidden ad/tracking iframes.
|
|
121
|
-
// Recordings are tracked per-Page, so an unguarded sub-frame session would
|
|
122
|
-
// corrupt the tab's event stream with bogus 0x0 "about:blank" entries.
|
|
123
|
-
// @ts-ignore
|
|
124
|
-
if (window.self !== window.top) return;
|
|
125
|
-
// @ts-ignore
|
|
126
|
-
if (window.rrwebRecord) {
|
|
127
|
-
// @ts-ignore
|
|
128
|
-
window.rrwebRecord.record({
|
|
129
|
-
emit: (event: unknown) => {
|
|
130
|
-
// @ts-ignore — exposed by BrowserContext.exposeBinding above
|
|
131
|
-
window.__plumEmitRRwebEvent(JSON.stringify(event));
|
|
132
|
-
}
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
_context.on('page', attachRecorder);
|
|
138
|
-
_page = await _context.newPage();
|
|
139
|
-
|
|
140
|
-
// Only when someone's actually watching live — a scheduled/background run
|
|
141
|
-
// with no viewer shouldn't pay for this.
|
|
142
|
-
if (process.env.PLUM_SS_DIR) {
|
|
143
|
-
_liveRRwebTimer = setInterval(flushLiveRRwebEvents, 500);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Sends only what's newly arrived since the last tick, per tab, so the live
|
|
148
|
-
// viewer gets a steady trickle instead of the full buffer growing unbounded.
|
|
149
|
-
function flushLiveRRwebEvents(): void {
|
|
150
|
-
const ssDir = process.env.PLUM_SS_DIR;
|
|
151
|
-
if (!ssDir) return;
|
|
152
|
-
for (const recording of _tabs.values()) {
|
|
153
|
-
const newEvents = recording.events.slice(recording.liveFlushedCount);
|
|
154
|
-
if (newEvents.length === 0) continue;
|
|
155
|
-
recording.liveFlushedCount = recording.events.length;
|
|
156
|
-
try {
|
|
157
|
-
const seq = `${String(Date.now()).padStart(16, '0')}-${String(++_liveRRwebCounter).padStart(4, '0')}`;
|
|
158
|
-
fs.writeFileSync(
|
|
159
|
-
path.join(ssDir, `${seq}.rrweb.json`),
|
|
160
|
-
JSON.stringify({
|
|
161
|
-
workerId: _workerId,
|
|
162
|
-
tabId: recording.tabId,
|
|
163
|
-
tabIndex: recording.tabIndex,
|
|
164
|
-
events: newEvents
|
|
165
|
-
})
|
|
166
|
-
);
|
|
167
|
-
} catch {
|
|
168
|
-
// best-effort — live streaming shouldn't affect the recording itself
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Injects a labeled rrweb custom event at the current recording timestamp so
|
|
175
|
-
* the replay UI can show which step was running at any point in the timeline.
|
|
176
|
-
*/
|
|
177
|
-
export async function markStepStart(stepName: string): Promise<void> {
|
|
178
|
-
if (!_page) return;
|
|
179
|
-
try {
|
|
180
|
-
await _page.evaluate((name) => {
|
|
181
|
-
// @ts-ignore — rrwebRecord is injected by the record.umd.min.cjs bundle
|
|
182
|
-
if (window.rrwebRecord?.record?.addCustomEvent) {
|
|
183
|
-
// @ts-ignore
|
|
184
|
-
window.rrwebRecord.record.addCustomEvent('step', { name });
|
|
185
|
-
}
|
|
186
|
-
}, stepName);
|
|
187
|
-
} catch {
|
|
188
|
-
// best-effort — a missing marker just means the replay UI won't show a
|
|
189
|
-
// step label at that point, it doesn't affect the recording itself
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Flushes every tab's buffered rrweb events (one per opened tab/popup) as a
|
|
195
|
-
* gzip-compressed Cucumber attachment, tagged with the mime type Plum's
|
|
196
|
-
* server looks for.
|
|
197
|
-
*/
|
|
198
|
-
export async function flushRecordings(
|
|
199
|
-
attach: (data: Buffer, mime: string) => Promise<void>
|
|
200
|
-
): Promise<void> {
|
|
201
|
-
if (_liveRRwebTimer) {
|
|
202
|
-
clearInterval(_liveRRwebTimer);
|
|
203
|
-
_liveRRwebTimer = null;
|
|
204
|
-
}
|
|
205
|
-
// One last live flush so the stream doesn't miss whatever happened between
|
|
206
|
-
// the final tick and scenario end.
|
|
207
|
-
flushLiveRRwebEvents();
|
|
208
|
-
|
|
209
|
-
try {
|
|
210
|
-
await attach(
|
|
211
|
-
Buffer.from(JSON.stringify({ workerId: _workerId }), 'utf8'),
|
|
212
|
-
WORKER_META_MIME_TYPE
|
|
213
|
-
);
|
|
214
|
-
} catch {
|
|
215
|
-
// best-effort — a missing worker marker just falls back to workerId 1
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const flushedAt = Date.now();
|
|
219
|
-
for (const recording of _tabs.values()) {
|
|
220
|
-
if (recording.events.length === 0) continue;
|
|
221
|
-
try {
|
|
222
|
-
const payload = JSON.stringify({
|
|
223
|
-
workerId: _workerId,
|
|
224
|
-
tabId: recording.tabId,
|
|
225
|
-
tabIndex: recording.tabIndex,
|
|
226
|
-
events: recording.events,
|
|
227
|
-
openedAt: recording.openedAt,
|
|
228
|
-
// A tab still open when the scenario ends (typically the main tab)
|
|
229
|
-
// stayed relevant through to the flush, not just its last DOM event.
|
|
230
|
-
closedAt: recording.closedAt ?? flushedAt
|
|
231
|
-
});
|
|
232
|
-
const gz = zlib.gzipSync(Buffer.from(payload, 'utf8'));
|
|
233
|
-
await attach(gz, RRWEB_MIME_TYPE);
|
|
234
|
-
} catch {
|
|
235
|
-
// a failed recording flush shouldn't fail the scenario
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
export async function teardown(): Promise<void> {
|
|
241
|
-
await _browser?.close();
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// Pickle steps carry no keyword (Cucumber normalizes Given/When/Then/And/But
|
|
245
|
-
// away during Gherkin → Pickle compilation) — recover it by walking the
|
|
246
|
-
// gherkinDocument for the AST node the pickle step was compiled from.
|
|
247
|
-
function resolveStepKeyword(gherkinDocument: any, pickleStep: any): string {
|
|
248
|
-
const astNodeId = pickleStep?.astNodeIds?.[0];
|
|
249
|
-
if (!astNodeId) return '';
|
|
250
|
-
const steps: any[] = [];
|
|
251
|
-
for (const child of gherkinDocument?.feature?.children ?? []) {
|
|
252
|
-
if (child.background) steps.push(...child.background.steps);
|
|
253
|
-
if (child.scenario) steps.push(...child.scenario.steps);
|
|
254
|
-
for (const ruleChild of child.rule?.children ?? []) {
|
|
255
|
-
if (ruleChild.background) steps.push(...ruleChild.background.steps);
|
|
256
|
-
if (ruleChild.scenario) steps.push(...ruleChild.scenario.steps);
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
return steps.find((s) => s.id === astNodeId)?.keyword?.trim() ?? '';
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
* Registers Plum's own Before/BeforeStep/After hooks. Call once from your
|
|
264
|
-
* own tests/utils/hooks.ts — Cucumber supports multiple Before/After hooks,
|
|
265
|
-
* so your own hooks can still be added alongside this.
|
|
266
|
-
*/
|
|
267
|
-
export function registerHooks(): void {
|
|
268
|
-
Before(async ({ pickle }: ITestCaseHookParameter) => {
|
|
269
|
-
const tags = pickle.tags.map((t) => t.name).join(' ');
|
|
270
|
-
console.log(`\n▶ ${pickle.name}${tags ? ` ${tags}` : ''}`);
|
|
271
|
-
await setup();
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
BeforeStep(async function ({
|
|
275
|
-
pickleStep,
|
|
276
|
-
gherkinDocument
|
|
277
|
-
}: {
|
|
278
|
-
pickleStep: any;
|
|
279
|
-
gherkinDocument: any;
|
|
280
|
-
}) {
|
|
281
|
-
const keyword = resolveStepKeyword(gherkinDocument, pickleStep);
|
|
282
|
-
const text = pickleStep?.text ?? '';
|
|
283
|
-
await markStepStart(keyword ? `${keyword} ${text}` : text);
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
After(async function () {
|
|
287
|
-
await flushRecordings(this.attach.bind(this));
|
|
288
|
-
await teardown();
|
|
289
|
-
});
|
|
290
|
-
}
|