plum-e2e 2.9.1 → 2.9.3

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,26 +79,33 @@ 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, 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 |
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.
102
109
 
103
110
  ---
104
111
 
@@ -15,12 +15,11 @@
15
15
  * along with Plum. If not, see https://www.gnu.org/licenses/.
16
16
  */
17
17
 
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';
18
+ import type { Page, BrowserContext, Browser } from 'playwright';
19
+ import * as plum from './plum-modules/runtime';
22
20
 
23
- const runtime = require(process.env.PLUM_RUNTIME_PATH as string);
21
+ export const page = (): Page => plum.page();
22
+ export const context = (): BrowserContext => plum.context();
23
+ export const browser = (): Browser => plum.browser();
24
24
 
25
- export const page = (): Page => runtime.page();
26
- export const context = (): BrowserContext => runtime.context();
25
+ // Add your own page/context helpers below.
@@ -15,9 +15,9 @@
15
15
  * along with Plum. If not, see https://www.gnu.org/licenses/.
16
16
  */
17
17
 
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();
18
+ import * as plum from './plum-modules/runtime';
19
+
20
+ plum.registerHooks();
21
21
 
22
22
  // Add your own custom Before/After/BeforeStep hooks below — Cucumber runs
23
23
  // every registered hook, so these run alongside Plum's own.
@@ -0,0 +1,24 @@
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,20 +1,29 @@
1
1
  /*
2
2
  * This file is part of Plum.
3
- * Licensed under the MIT License. See LICENSE file in the project root for details.
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/.
4
16
  */
5
17
 
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.
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.
11
20
 
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');
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';
18
27
 
19
28
  dotenv.config();
20
29
 
@@ -31,19 +40,29 @@ const RECORD_BUNDLE_PATH = path.join(
31
40
  'record.umd.min.cjs'
32
41
  );
33
42
 
34
- let _browser;
35
- let _context;
36
- let _page;
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;
37
55
  let _liveRRwebCounter = 0;
38
- let _liveRRwebTimer = null;
39
- let _tabs = new Map();
56
+ let _liveRRwebTimer: ReturnType<typeof setInterval> | null = null;
57
+ let _tabs: Map<Page, TabRecording> = new Map();
40
58
  let _tabCounter = 0;
41
59
  let _workerId = 1;
42
60
 
43
- const page = () => _page;
44
- const context = () => _context;
61
+ export const page = (): Page => _page;
62
+ export const context = (): BrowserContext => _context;
63
+ export const browser = (): Browser => _browser;
45
64
 
46
- function tabIdForIndex(index) {
65
+ function tabIdForIndex(index: number): string {
47
66
  return index === 0 ? 'main' : `tab-${index + 1}`;
48
67
  }
49
68
 
@@ -52,9 +71,9 @@ function tabIdForIndex(index) {
52
71
  // timestamps are a poor proxy for how long it stayed relevant. Real
53
72
  // open/close times let the replay UI line multiple tabs up on one timeline
54
73
  // without guessing from event gaps.
55
- function attachRecorder(pg) {
74
+ function attachRecorder(pg: Page): void {
56
75
  const tabIndex = _tabCounter++;
57
- const recording = {
76
+ const recording: TabRecording = {
58
77
  tabId: tabIdForIndex(tabIndex),
59
78
  tabIndex,
60
79
  events: [],
@@ -68,7 +87,7 @@ function attachRecorder(pg) {
68
87
  });
69
88
  }
70
89
 
71
- async function setup() {
90
+ export async function setup(): Promise<void> {
72
91
  const isHeadless = process.env.IS_HEADLESS?.toLowerCase() !== 'false';
73
92
  const browserName = (process.env.BROWSER || 'chromium').toLowerCase();
74
93
  const browserType =
@@ -87,7 +106,7 @@ async function setup() {
87
106
  // Context-level exposeBinding/addInitScript apply to every page in the
88
107
  // context automatically — current and future (popups, target=_blank tabs) —
89
108
  // so recording setup never races a new tab's first navigation.
90
- await _context.exposeBinding('__plumEmitRRwebEvent', (source, eventJson) => {
109
+ await _context.exposeBinding('__plumEmitRRwebEvent', (source, eventJson: string) => {
91
110
  const recording = source.page && _tabs.get(source.page);
92
111
  if (!recording) return;
93
112
  try {
@@ -101,11 +120,14 @@ async function setup() {
101
120
  // addInitScript runs in every frame, including hidden ad/tracking iframes.
102
121
  // Recordings are tracked per-Page, so an unguarded sub-frame session would
103
122
  // corrupt the tab's event stream with bogus 0x0 "about:blank" entries.
123
+ // @ts-ignore
104
124
  if (window.self !== window.top) return;
125
+ // @ts-ignore
105
126
  if (window.rrwebRecord) {
127
+ // @ts-ignore
106
128
  window.rrwebRecord.record({
107
- emit: (event) => {
108
- // exposed by BrowserContext.exposeBinding above
129
+ emit: (event: unknown) => {
130
+ // @ts-ignore — exposed by BrowserContext.exposeBinding above
109
131
  window.__plumEmitRRwebEvent(JSON.stringify(event));
110
132
  }
111
133
  });
@@ -124,7 +146,7 @@ async function setup() {
124
146
 
125
147
  // Sends only what's newly arrived since the last tick, per tab, so the live
126
148
  // viewer gets a steady trickle instead of the full buffer growing unbounded.
127
- function flushLiveRRwebEvents() {
149
+ function flushLiveRRwebEvents(): void {
128
150
  const ssDir = process.env.PLUM_SS_DIR;
129
151
  if (!ssDir) return;
130
152
  for (const recording of _tabs.values()) {
@@ -148,13 +170,17 @@ function flushLiveRRwebEvents() {
148
170
  }
149
171
  }
150
172
 
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) {
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> {
154
178
  if (!_page) return;
155
179
  try {
156
180
  await _page.evaluate((name) => {
181
+ // @ts-ignore — rrwebRecord is injected by the record.umd.min.cjs bundle
157
182
  if (window.rrwebRecord?.record?.addCustomEvent) {
183
+ // @ts-ignore
158
184
  window.rrwebRecord.record.addCustomEvent('step', { name });
159
185
  }
160
186
  }, stepName);
@@ -164,10 +190,14 @@ async function markStepStart(stepName) {
164
190
  }
165
191
  }
166
192
 
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) {
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> {
171
201
  if (_liveRRwebTimer) {
172
202
  clearInterval(_liveRRwebTimer);
173
203
  _liveRRwebTimer = null;
@@ -207,17 +237,17 @@ async function flushRecordings(attach) {
207
237
  }
208
238
  }
209
239
 
210
- async function teardown() {
240
+ export async function teardown(): Promise<void> {
211
241
  await _browser?.close();
212
242
  }
213
243
 
214
244
  // Pickle steps carry no keyword (Cucumber normalizes Given/When/Then/And/But
215
245
  // away during Gherkin → Pickle compilation) — recover it by walking the
216
246
  // gherkinDocument for the AST node the pickle step was compiled from.
217
- function resolveStepKeyword(gherkinDocument, pickleStep) {
247
+ function resolveStepKeyword(gherkinDocument: any, pickleStep: any): string {
218
248
  const astNodeId = pickleStep?.astNodeIds?.[0];
219
249
  if (!astNodeId) return '';
220
- const steps = [];
250
+ const steps: any[] = [];
221
251
  for (const child of gherkinDocument?.feature?.children ?? []) {
222
252
  if (child.background) steps.push(...child.background.steps);
223
253
  if (child.scenario) steps.push(...child.scenario.steps);
@@ -229,17 +259,25 @@ function resolveStepKeyword(gherkinDocument, pickleStep) {
229
259
  return steps.find((s) => s.id === astNodeId)?.keyword?.trim() ?? '';
230
260
  }
231
261
 
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 }) => {
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) => {
237
269
  const tags = pickle.tags.map((t) => t.name).join(' ');
238
270
  console.log(`\n▶ ${pickle.name}${tags ? ` ${tags}` : ''}`);
239
271
  await setup();
240
272
  });
241
273
 
242
- BeforeStep(async function ({ pickleStep, gherkinDocument }) {
274
+ BeforeStep(async function ({
275
+ pickleStep,
276
+ gherkinDocument
277
+ }: {
278
+ pickleStep: any;
279
+ gherkinDocument: any;
280
+ }) {
243
281
  const keyword = resolveStepKeyword(gherkinDocument, pickleStep);
244
282
  const text = pickleStep?.text ?? '';
245
283
  await markStepStart(keyword ? `${keyword} ${text}` : text);
@@ -250,13 +288,3 @@ function registerHooks() {
250
288
  await teardown();
251
289
  });
252
290
  }
253
-
254
- module.exports = {
255
- page,
256
- context,
257
- setup,
258
- teardown,
259
- flushRecordings,
260
- markStepStart,
261
- registerHooks
262
- };
@@ -29,6 +29,19 @@ 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 });
32
45
 
33
46
  // Dispatched tests run from an external dir (e.g. a temp dir on a node) that has
34
47
  // no node_modules of its own — point Node at the backend's modules so imports
@@ -112,11 +125,7 @@ try {
112
125
  // When running from a temp dir there is no tsconfig.json above it, so
113
126
  // ts-node falls back to defaults that may conflict with the Node version.
114
127
  // Point it at the backend tsconfig explicitly.
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')
128
+ ...(execCwd && { TS_NODE_PROJECT: path.resolve(__dirname, '..', '..', 'tsconfig.json') })
120
129
  }
121
130
  });
122
131
  } catch (error) {
@@ -11,9 +11,8 @@ 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/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.
14
+ // Matched by string literal in backend/_scaffold/utils/plum-modules/runtime.ts
15
+ // (flushRecordings) — the two runtimes don't share a module.
17
16
  const RRWEB_MIME_TYPE = 'application/x-plum-rrweb+json';
18
17
  // Small, always-attached marker (independent of whether any tab actually
19
18
  // recorded events) so a scenario's worker is always recoverable for grouping,
package/bin/plum.js CHANGED
@@ -62,25 +62,57 @@ 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).
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.
71
81
  const INFRA_SCAFFOLD_FILES = ['utils/browser.ts', 'utils/hooks.ts'];
72
82
 
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) {
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 } = {}) {
78
107
  if (!fs.existsSync(testsDir)) {
79
108
  clack.log.warn(`No \`tests/\` folder found at ${testsDir} — skipping scaffold sync.`);
80
109
  return;
81
110
  }
82
111
 
83
- let updated = 0;
112
+ syncPlumModulesDir(testsDir);
113
+
114
+ let changed = 0;
115
+ let stale = 0;
84
116
  for (const relPath of INFRA_SCAFFOLD_FILES) {
85
117
  const src = path.join(scaffoldTestsPath, relPath);
86
118
  const dest = path.join(testsDir, relPath);
@@ -90,16 +122,50 @@ function syncScaffoldInfraFiles(testsDir) {
90
122
  const latest = fs.readFileSync(src, 'utf8');
91
123
  if (current === latest) continue;
92
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
+
93
159
  const backupPath = `${dest}.bak-${Date.now()}`;
94
160
  fs.copyFileSync(dest, backupPath);
95
161
  fs.copyFileSync(src, dest);
96
- updated++;
162
+ changed++;
97
163
  clack.log.success(
98
164
  `Updated ${relPath} (previous version backed up to ${path.basename(backupPath)})`
99
165
  );
100
166
  }
101
167
 
102
- if (updated === 0) {
168
+ if (changed === 0 && stale === 0) {
103
169
  clack.log.info('Test scaffold wiring is already up to date.');
104
170
  }
105
171
  }
@@ -553,9 +619,31 @@ async function serverUpdate() {
553
619
  // logic no matter how new the just-installed files on disk actually are.
554
620
  for (const dir of getInstalls('server')) {
555
621
  if (!fs.existsSync(path.join(dir, '.plum-server.json'))) continue;
622
+
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.
556
628
  if (fs.existsSync(path.join(dir, 'tests'))) {
557
629
  syncScaffoldInfraFiles(path.join(dir, 'tests'));
558
630
  }
631
+
632
+ // This registry is global to the machine, not scoped to the directory
633
+ // `plum update` was run from — an unrelated project on the same machine
634
+ // as a registered server would otherwise silently boot that server's
635
+ // Docker stack. Ask first whenever there's someone to ask; a
636
+ // non-interactive run (CI, cron, systemd) has no one to ask and keeps
637
+ // the previous unconditional behavior.
638
+ if (interactiveAllowed()) {
639
+ const proceed = await clack.confirm({
640
+ message: `Found a registered server at ${dir} — restart it?`,
641
+ initialValue: true
642
+ });
643
+ if (clack.isCancel(proceed)) cancelAndExit();
644
+ if (!proceed) continue;
645
+ }
646
+
559
647
  clack.log.step(`Rebuilding server at ${dir}…`);
560
648
  try {
561
649
  execSync('plum server restart', { stdio: 'inherit', cwd: dir });
@@ -568,9 +656,24 @@ async function serverUpdate() {
568
656
  for (const dir of getInstalls('node')) {
569
657
  const nodeCfg = loadNodeConfig(dir);
570
658
  if (!nodeCfg.id) continue;
659
+
660
+ // Same reasoning as the server loop above: runs regardless of whether
661
+ // the restart below gets confirmed.
571
662
  if (fs.existsSync(path.join(dir, 'tests'))) {
572
663
  syncScaffoldInfraFiles(path.join(dir, 'tests'));
573
664
  }
665
+
666
+ // This registry spans the whole machine, not just the directory
667
+ // `plum update` was run from.
668
+ if (interactiveAllowed()) {
669
+ const proceed = await clack.confirm({
670
+ message: `Found a registered node at ${dir} — restart it?`,
671
+ initialValue: true
672
+ });
673
+ if (clack.isCancel(proceed)) cancelAndExit();
674
+ if (!proceed) continue;
675
+ }
676
+
574
677
  // Always attempt the restart rather than gating on the local PID
575
678
  // registry: that registry goes stale (manager restarts, pre-existing
576
679
  // installs from before this tracking existed, etc.), and skipping the
@@ -1141,11 +1244,13 @@ switch (command) {
1141
1244
  await serverUpdate();
1142
1245
  break;
1143
1246
 
1144
- case 'sync-scaffold':
1247
+ case 'sync-scaffold': {
1145
1248
  clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Sync Test Scaffold ')));
1146
- syncScaffoldInfraFiles(userTestsPath);
1249
+ const force = anyFlags(process.argv.slice(3), ['--force']);
1250
+ syncScaffoldInfraFiles(userTestsPath, { force });
1147
1251
  clack.outro(pc.green('Done.'));
1148
1252
  break;
1253
+ }
1149
1254
 
1150
1255
  case 'run-test': {
1151
1256
  const runHelpArgs = process.argv.slice(3);
@@ -1353,10 +1458,13 @@ switch (command) {
1353
1458
  console.log(' server stop Stop the server (data preserved)');
1354
1459
  console.log(' server reconfig Re-enter server settings without starting');
1355
1460
  console.log(
1356
- ' update Update Plum, restart whichever is running (server/node), and re-sync tests/ wiring'
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'
1357
1465
  );
1358
1466
  console.log(
1359
- ' sync-scaffold Re-sync browser.ts/hooks.ts in tests/ from the installed Plum version'
1467
+ ' --force Overwrite files that differ (previous version backed up first)'
1360
1468
  );
1361
1469
  console.log(' node start Start a runner node (interactive), then open runner menu');
1362
1470
  console.log(' --primary <url> Primary Plum server to auto-register with');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plum-e2e",
3
- "version": "2.9.1",
3
+ "version": "2.9.3",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/silverlunah/plum.git"