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 +20 -19
- package/backend/_scaffold/utils/browser.ts +7 -217
- package/backend/_scaffold/utils/hooks.ts +5 -49
- package/backend/config/scripts/run-tests.js +5 -1
- package/backend/lib/plumTestRuntime.js +262 -0
- package/bin/plum.js +58 -1
- package/frontend/.svelte-kit/ambient.d.ts +28 -28
- package/package.json +1 -1
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
|
|
90
|
-
| `plum
|
|
91
|
-
| `plum node
|
|
92
|
-
| `plum node
|
|
93
|
-
| `plum node
|
|
94
|
-
| `plum
|
|
95
|
-
| `plum run-test
|
|
96
|
-
| `plum run-test
|
|
97
|
-
| `plum run-test --
|
|
98
|
-
| `plum run-test --
|
|
99
|
-
| `plum
|
|
100
|
-
| `plum
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
const
|
|
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
|
-
//
|
|
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
|
-
|
|
21
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
75
|
+
export const npm_config_init_module: string;
|
|
76
|
+
export const npm_config_userconfig: string;
|
|
77
77
|
export const PWD: string;
|
|
78
|
-
export const
|
|
79
|
-
export const EDITOR: string;
|
|
78
|
+
export const npm_command: string;
|
|
80
79
|
export const OUTLINE_API_KEY: string;
|
|
81
|
-
export const
|
|
80
|
+
export const EDITOR: string;
|
|
81
|
+
export const npm_lifecycle_event: string;
|
|
82
82
|
export const LANG: string;
|
|
83
|
-
export const
|
|
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
|
|
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
|
-
|
|
176
|
+
npm_package_bin_plum: string;
|
|
177
177
|
npm_config_engine_strict: string;
|
|
178
178
|
_: string;
|
|
179
|
-
|
|
180
|
-
npm_config_init_module: string;
|
|
179
|
+
npm_package_json: string;
|
|
181
180
|
USER_ZDOTDIR: string;
|
|
182
181
|
__CFBundleIdentifier: string;
|
|
183
|
-
|
|
182
|
+
npm_config_init_module: string;
|
|
183
|
+
npm_config_userconfig: string;
|
|
184
184
|
PWD: string;
|
|
185
|
-
|
|
186
|
-
EDITOR: string;
|
|
185
|
+
npm_command: string;
|
|
187
186
|
OUTLINE_API_KEY: string;
|
|
188
|
-
|
|
187
|
+
EDITOR: string;
|
|
188
|
+
npm_lifecycle_event: string;
|
|
189
189
|
LANG: string;
|
|
190
|
-
|
|
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
|
-
|
|
215
|
+
npm_config_prefix: string;
|
|
216
|
+
npm_node_execpath: string;
|
|
217
217
|
[key: `PUBLIC_${string}`]: undefined;
|
|
218
218
|
[key: `${string}`]: string | undefined;
|
|
219
219
|
}
|