@beryl-so/cli 0.14.1 → 0.21.4
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 +32 -29
- package/dist/adapters/cli.js +15 -1
- package/dist/adapters/mcp.js +44 -5
- package/dist/beryl-test-skill.js +281 -47
- package/dist/commands/accounts.js +331 -0
- package/dist/commands/config-vars.js +23 -2
- package/dist/commands/environments.js +0 -2
- package/dist/commands/init.js +43 -27
- package/dist/commands/mailboxes.js +159 -0
- package/dist/commands/mcp.js +25 -0
- package/dist/commands/projects.js +21 -11
- package/dist/commands/runs.js +273 -67
- package/dist/commands/tests.js +263 -19
- package/dist/context.js +33 -2
- package/dist/email-extract.js +99 -0
- package/dist/email-pump.js +105 -0
- package/dist/http.js +4 -1
- package/dist/local-exec.js +198 -0
- package/dist/local-run.js +145 -4
- package/dist/output.js +19 -0
- package/dist/playwright-install.js +118 -7
- package/dist/progress.js +44 -0
- package/dist/registry/index.js +4 -4
- package/dist/schema.generated.js +34 -0
- package/package.json +2 -2
- package/dist/commands/credentials.js +0 -135
- package/dist/commands/inboxes.js +0 -166
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { startEmailPump } from "./email-pump.js";
|
|
4
|
+
import { PlaywrightMissingError, runSpecLocally, } from "./local-run.js";
|
|
5
|
+
// Client-side mirrors of the server's RUN_IMPORT_* caps: stay under them rather than
|
|
6
|
+
// discover them as a 413/422.
|
|
7
|
+
export const IMPORT_MAX_ERROR_LEN = 5000;
|
|
8
|
+
export const IMPORT_MAX_FILE_BYTES = 14 * 1024 * 1024;
|
|
9
|
+
export const IMPORT_MAX_TOTAL_BYTES = 180 * 1024 * 1024;
|
|
10
|
+
export const IMPORT_MAX_FILES = 3600;
|
|
11
|
+
export function toRunEntry(spec, outcome, runError, startedAt, ordinal, redact) {
|
|
12
|
+
// Mirrors the cloud runner's redact_result: the spec types the secret into the
|
|
13
|
+
// page, so error text and the DOM snapshot can echo it back — scrub before the
|
|
14
|
+
// bytes leave this machine. Frames and screenshots are pixels; nothing to scrub.
|
|
15
|
+
const scrub = (text) => (redact ? text.split(redact).join("***") : text);
|
|
16
|
+
const scrubBytes = (bytes) => redact && bytes.includes(redact)
|
|
17
|
+
? Buffer.from(bytes.toString("utf8").split(redact).join("***"), "utf8")
|
|
18
|
+
: bytes;
|
|
19
|
+
const completedAt = new Date().toISOString();
|
|
20
|
+
const base = {
|
|
21
|
+
test_case_id: spec.id,
|
|
22
|
+
started_at: startedAt,
|
|
23
|
+
completed_at: completedAt,
|
|
24
|
+
frames: [],
|
|
25
|
+
frame_urls: [],
|
|
26
|
+
frame_durations_ms: [],
|
|
27
|
+
files: [],
|
|
28
|
+
};
|
|
29
|
+
if (!outcome) {
|
|
30
|
+
return {
|
|
31
|
+
...base,
|
|
32
|
+
status: "errored",
|
|
33
|
+
phase: "main",
|
|
34
|
+
error_message: scrub(runError ?? "the spec did not run").slice(0, IMPORT_MAX_ERROR_LEN),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const result = outcome.results[0];
|
|
38
|
+
const status = result && (result.status === "passed" || result.status === "expected")
|
|
39
|
+
? "passed"
|
|
40
|
+
: "failed";
|
|
41
|
+
const harvested = outcome.harvested;
|
|
42
|
+
const entry = {
|
|
43
|
+
...base,
|
|
44
|
+
status,
|
|
45
|
+
phase: status === "failed" ? (harvested?.phase ?? "main") : "main",
|
|
46
|
+
...(result?.error
|
|
47
|
+
? { error_message: scrub(result.error).slice(0, IMPORT_MAX_ERROR_LEN) }
|
|
48
|
+
: {}),
|
|
49
|
+
...(result?.duration_ms !== undefined ? { duration_ms: Math.max(0, result.duration_ms) } : {}),
|
|
50
|
+
};
|
|
51
|
+
if (!harvested)
|
|
52
|
+
return entry;
|
|
53
|
+
// A frame over the per-file cap would force dropping mid-list and shift the
|
|
54
|
+
// index-aligned url/duration sidecars — drop the whole filmstrip instead.
|
|
55
|
+
// Drop the inlined sign-in's frames before anything is index-aligned to the stored
|
|
56
|
+
// plan: `runs explain` zips the plan's steps against these lists, so an off-by-K here
|
|
57
|
+
// shows every step's screenshot next to the wrong step.
|
|
58
|
+
const skip = Math.min(spec.inlinedLoginSteps ?? 0, harvested.frames.length);
|
|
59
|
+
const frames = harvested.frames.slice(skip);
|
|
60
|
+
if (frames.every((f) => f.length <= IMPORT_MAX_FILE_BYTES)) {
|
|
61
|
+
frames.forEach((bytes, i) => {
|
|
62
|
+
const name = `r${ordinal}-frame-${String(i).padStart(3, "0")}.png`;
|
|
63
|
+
entry.frames.push(name);
|
|
64
|
+
entry.files.push({ name, bytes });
|
|
65
|
+
});
|
|
66
|
+
entry.frame_urls = harvested.frameUrls.slice(skip);
|
|
67
|
+
entry.frame_durations_ms = harvested.frameDurationsMs.slice(skip);
|
|
68
|
+
}
|
|
69
|
+
if (harvested.screenshot && harvested.screenshot.length <= IMPORT_MAX_FILE_BYTES) {
|
|
70
|
+
entry.screenshot = `r${ordinal}-screenshot.png`;
|
|
71
|
+
entry.files.push({ name: entry.screenshot, bytes: harvested.screenshot });
|
|
72
|
+
}
|
|
73
|
+
if (harvested.domSnapshot && harvested.domSnapshot.length <= IMPORT_MAX_FILE_BYTES) {
|
|
74
|
+
entry.dom_snapshot = `r${ordinal}-dom.html`;
|
|
75
|
+
entry.files.push({ name: entry.dom_snapshot, bytes: scrubBytes(harvested.domSnapshot) });
|
|
76
|
+
}
|
|
77
|
+
return entry;
|
|
78
|
+
}
|
|
79
|
+
export function buildImportForm(entries, opts) {
|
|
80
|
+
// Total-size budget: when a big suite would blow past the server's cap, shed whole
|
|
81
|
+
// filmstrips (largest droppable payload, replay-only) test by test — never
|
|
82
|
+
// mid-list, and never silently.
|
|
83
|
+
let total = entries.flatMap((e) => e.files).reduce((n, f) => n + f.bytes.length, 0);
|
|
84
|
+
for (const entry of entries) {
|
|
85
|
+
if (total <= IMPORT_MAX_TOTAL_BYTES)
|
|
86
|
+
break;
|
|
87
|
+
const frameBytes = entry.files
|
|
88
|
+
.filter((f) => entry.frames.includes(f.name))
|
|
89
|
+
.reduce((n, f) => n + f.bytes.length, 0);
|
|
90
|
+
if (frameBytes === 0)
|
|
91
|
+
continue;
|
|
92
|
+
entry.files = entry.files.filter((f) => !entry.frames.includes(f.name));
|
|
93
|
+
entry.frames = [];
|
|
94
|
+
entry.frame_urls = [];
|
|
95
|
+
entry.frame_durations_ms = [];
|
|
96
|
+
total -= frameBytes;
|
|
97
|
+
opts.onNote?.(`replay frames for test ${entry.test_case_id} dropped (upload size cap)`);
|
|
98
|
+
}
|
|
99
|
+
// File-count budget: the server parses at most RUN_IMPORT_MAX_FILES multipart
|
|
100
|
+
// parts, so a big suite's filmstrips can overflow on count with bytes to spare.
|
|
101
|
+
let fileCount = entries.reduce((n, e) => n + e.files.length, 0);
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
if (fileCount <= IMPORT_MAX_FILES)
|
|
104
|
+
break;
|
|
105
|
+
const frameFiles = entry.files.filter((f) => entry.frames.includes(f.name)).length;
|
|
106
|
+
if (frameFiles === 0)
|
|
107
|
+
continue;
|
|
108
|
+
entry.files = entry.files.filter((f) => !entry.frames.includes(f.name));
|
|
109
|
+
entry.frames = [];
|
|
110
|
+
entry.frame_urls = [];
|
|
111
|
+
entry.frame_durations_ms = [];
|
|
112
|
+
fileCount -= frameFiles;
|
|
113
|
+
opts.onNote?.(`replay frames for test ${entry.test_case_id} dropped (upload file-count cap)`);
|
|
114
|
+
}
|
|
115
|
+
const manifest = {
|
|
116
|
+
environment_id: opts.environmentId ?? null,
|
|
117
|
+
target_url_override: opts.targetUrlOverride ?? null,
|
|
118
|
+
...(opts.notifications !== undefined ? { notifications: opts.notifications } : {}),
|
|
119
|
+
started_at: opts.startedAt,
|
|
120
|
+
completed_at: opts.completedAt,
|
|
121
|
+
results: entries.map(({ files: _files, ...rest }) => rest),
|
|
122
|
+
};
|
|
123
|
+
const form = new FormData();
|
|
124
|
+
form.append("manifest", JSON.stringify(manifest));
|
|
125
|
+
for (const entry of entries) {
|
|
126
|
+
for (const file of entry.files) {
|
|
127
|
+
form.append("files", new Blob([new Uint8Array(file.bytes)]), file.name);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return form;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Run one rendered spec on this machine with its full service harness: write the
|
|
134
|
+
* inbox/login sidecars and pump `await_email` requests over the API while Playwright
|
|
135
|
+
* runs. The one local-execution seam shared by `runs local` and the local-verify
|
|
136
|
+
* `tests create`. {@link PlaywrightMissingError} propagates (environmental — the
|
|
137
|
+
* caller decides whether to offer an install); any other throw is captured as
|
|
138
|
+
* `runError` (the spec never ran: an errored result, not a crashed command).
|
|
139
|
+
*/
|
|
140
|
+
export async function executeLocalSpec(deps, opts) {
|
|
141
|
+
const { spec } = opts;
|
|
142
|
+
const inbox = spec.email
|
|
143
|
+
? {
|
|
144
|
+
id: spec.email.inbox_id,
|
|
145
|
+
address: spec.email.address,
|
|
146
|
+
recipientContains: spec.email.recipient_contains ?? undefined,
|
|
147
|
+
// NOW, not the mailbox's creation time: it already holds every earlier run's
|
|
148
|
+
// mail, and an older code served as this run's would pass the step with a value
|
|
149
|
+
// the app then rejects. Sent as stored_after, which fences on the server's own
|
|
150
|
+
// INGESTION time and is clamped server-side — `since` compares against the
|
|
151
|
+
// sender's Date: header, so a fast local clock would fence out mail that arrived.
|
|
152
|
+
storedAfter: new Date().toISOString(),
|
|
153
|
+
}
|
|
154
|
+
: undefined;
|
|
155
|
+
try {
|
|
156
|
+
const outcome = await runSpecLocally({
|
|
157
|
+
spec: spec.content,
|
|
158
|
+
testName: spec.title,
|
|
159
|
+
dir: opts.dir,
|
|
160
|
+
harvest: opts.harvest,
|
|
161
|
+
redact: spec.usesLoginPassword ? opts.loginPassword : undefined,
|
|
162
|
+
setup: inbox || (spec.usesLoginPassword && opts.loginPassword !== undefined)
|
|
163
|
+
? (runDir) => {
|
|
164
|
+
if (inbox)
|
|
165
|
+
fs.writeFileSync(path.join(runDir, "email-inbox.json"), JSON.stringify({ address: inbox.address }), "utf8");
|
|
166
|
+
if (spec.usesLoginPassword && opts.loginPassword !== undefined)
|
|
167
|
+
fs.writeFileSync(path.join(runDir, "run-config.json"), JSON.stringify({ login_password: opts.loginPassword }), "utf8");
|
|
168
|
+
}
|
|
169
|
+
: undefined,
|
|
170
|
+
during: (runDir) => {
|
|
171
|
+
const pump = inbox
|
|
172
|
+
? startEmailPump({
|
|
173
|
+
sidecarPath: path.join(runDir, "email-inbox.json"),
|
|
174
|
+
client: deps.client,
|
|
175
|
+
workspaceId: deps.workspaceId,
|
|
176
|
+
inboxId: inbox.id,
|
|
177
|
+
storedAfter: inbox.storedAfter,
|
|
178
|
+
recipientContains: inbox.recipientContains,
|
|
179
|
+
onEvent: opts.onEvent,
|
|
180
|
+
})
|
|
181
|
+
: undefined;
|
|
182
|
+
const extra = opts.onSpawn?.(runDir);
|
|
183
|
+
return () => {
|
|
184
|
+
extra?.();
|
|
185
|
+
pump?.stop();
|
|
186
|
+
};
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
return { outcome };
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
if (err instanceof PlaywrightMissingError)
|
|
193
|
+
throw err;
|
|
194
|
+
// The spec never ran (compile error, filtered away) — an errored result the
|
|
195
|
+
// caller records, not an aborted command.
|
|
196
|
+
return { runError: err instanceof Error ? err.message : String(err) };
|
|
197
|
+
}
|
|
198
|
+
}
|
package/dist/local-run.js
CHANGED
|
@@ -21,6 +21,13 @@ const ISOLATING_CONFIG = (testDir, artifactsDir) => `import { defineConfig } fro
|
|
|
21
21
|
` outputDir: ${JSON.stringify(artifactsDir)},\n` +
|
|
22
22
|
` fullyParallel: false,\n` +
|
|
23
23
|
`});\n`;
|
|
24
|
+
export function copyTextScrubbed(src, dest, redact) {
|
|
25
|
+
if (!redact) {
|
|
26
|
+
fs.copyFileSync(src, dest);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
fs.writeFileSync(dest, fs.readFileSync(src, "utf8").split(redact).join("***"));
|
|
30
|
+
}
|
|
24
31
|
const PASSING = new Set(["passed", "expected"]);
|
|
25
32
|
const SKIPPED = new Set(["skipped"]);
|
|
26
33
|
export class PlaywrightMissingError extends Error {
|
|
@@ -95,6 +102,105 @@ export function tally(results) {
|
|
|
95
102
|
function parseReport(reportPath) {
|
|
96
103
|
return parsePlaywrightReport(JSON.parse(fs.readFileSync(reportPath, "utf8")));
|
|
97
104
|
}
|
|
105
|
+
/** How many filmstrip frames the rendered spec will write — the step total a live
|
|
106
|
+
* progress bar counts against. 0 when the spec was rendered without frame capture. */
|
|
107
|
+
export function countPlannedFrames(spec) {
|
|
108
|
+
return spec.match(/frames\/step-\d+\.png/g)?.length ?? 0;
|
|
109
|
+
}
|
|
110
|
+
export function countWrittenFrames(runDir) {
|
|
111
|
+
try {
|
|
112
|
+
return fs
|
|
113
|
+
.readdirSync(path.join(runDir, "frames"))
|
|
114
|
+
.filter((f) => /^step-\d+\.png$/.test(f)).length;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const readIfExists = (p) => {
|
|
121
|
+
try {
|
|
122
|
+
return fs.readFileSync(p);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
function findFirstFile(base, test) {
|
|
129
|
+
let entries;
|
|
130
|
+
try {
|
|
131
|
+
entries = fs.readdirSync(base, { withFileTypes: true });
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
137
|
+
const full = path.join(base, entry.name);
|
|
138
|
+
if (entry.isFile() && test(entry.name))
|
|
139
|
+
return readIfExists(full);
|
|
140
|
+
if (entry.isDirectory()) {
|
|
141
|
+
const nested = findFirstFile(full, test);
|
|
142
|
+
if (nested)
|
|
143
|
+
return nested;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
// Mirrors the cloud runner's workdir harvest (_harvest_frames/_harvest_frame_urls/
|
|
149
|
+
// _harvest_frame_step_durations/_harvest_dom_snapshot/_harvest_phase): the same
|
|
150
|
+
// sorted step-*.png order drives all three frame lists so they stay index-aligned.
|
|
151
|
+
function harvestArtifacts(runDir, artifactsDir) {
|
|
152
|
+
const framesDir = path.join(runDir, "frames");
|
|
153
|
+
let framePaths = [];
|
|
154
|
+
try {
|
|
155
|
+
framePaths = fs
|
|
156
|
+
.readdirSync(framesDir)
|
|
157
|
+
.filter((f) => /^step-\d+\.png$/.test(f))
|
|
158
|
+
.sort()
|
|
159
|
+
.map((f) => path.join(framesDir, f));
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// no frames dir — spec rendered without capture, or it never got that far
|
|
163
|
+
}
|
|
164
|
+
const frames = [];
|
|
165
|
+
const frameUrls = [];
|
|
166
|
+
const stamps = [];
|
|
167
|
+
for (const p of framePaths) {
|
|
168
|
+
const png = readIfExists(p);
|
|
169
|
+
if (!png)
|
|
170
|
+
continue;
|
|
171
|
+
frames.push(png);
|
|
172
|
+
frameUrls.push(readIfExists(p.replace(/\.png$/, ".url"))?.toString("utf8").trim() ?? "");
|
|
173
|
+
const raw = readIfExists(p.replace(/\.png$/, ".ts"))?.toString("utf8").trim();
|
|
174
|
+
const stamp = raw ? Number(raw) : NaN;
|
|
175
|
+
stamps.push(Number.isFinite(stamp) ? stamp : null);
|
|
176
|
+
}
|
|
177
|
+
const frameDurationsMs = stamps.map((stamp, i) => {
|
|
178
|
+
if (i === 0)
|
|
179
|
+
return 0;
|
|
180
|
+
const prev = stamps[i - 1];
|
|
181
|
+
return stamp !== null && prev !== null && prev !== undefined
|
|
182
|
+
? Math.max(0, stamp - prev)
|
|
183
|
+
: null;
|
|
184
|
+
});
|
|
185
|
+
let phase = "main";
|
|
186
|
+
try {
|
|
187
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(runDir, "phase.json"), "utf8"));
|
|
188
|
+
if (parsed.phase && ["setup", "main", "teardown"].includes(parsed.phase)) {
|
|
189
|
+
phase = parsed.phase;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
// no phase sidecar — an unsectioned spec
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
frames,
|
|
197
|
+
frameUrls,
|
|
198
|
+
frameDurationsMs,
|
|
199
|
+
screenshot: findFirstFile(artifactsDir, (n) => n.endsWith(".png")),
|
|
200
|
+
domSnapshot: readIfExists(path.join(runDir, "dom-snapshot.html")),
|
|
201
|
+
phase,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
98
204
|
/**
|
|
99
205
|
* Write `spec` next to the caller's project, run it with their local @playwright/test, and
|
|
100
206
|
* parse the JSON report into a structured pass/fail summary. Artifacts + the spec + report land
|
|
@@ -109,6 +215,11 @@ export async function runSpecLocally(opts) {
|
|
|
109
215
|
// /tmp finds no node_modules and every run dies with "Cannot find module '@playwright/test'".
|
|
110
216
|
// A run dir under cwd walks up into the project's node_modules; it's always cleaned up.
|
|
111
217
|
const runDir = fs.mkdtempSync(path.join(cwd, ".beryl-local-"));
|
|
218
|
+
// The spec's sidecar helpers (__vmInbox/__vmRunConfig) read files via require(),
|
|
219
|
+
// which dies with "require is not defined" when the surrounding project is
|
|
220
|
+
// "type": "module" (the spec then compiles as ESM). Scoping the run dir back to
|
|
221
|
+
// CommonJS matches the cloud workdir; node_modules resolution still walks up.
|
|
222
|
+
fs.writeFileSync(path.join(runDir, "package.json"), '{"type":"commonjs"}\n');
|
|
112
223
|
// Where the user-facing outputs (artifacts, spec copy, report) go: --dir if asked, else the
|
|
113
224
|
// ephemeral run dir.
|
|
114
225
|
const outDir = opts.dir ? path.resolve(opts.dir) : runDir;
|
|
@@ -123,8 +234,19 @@ export async function runSpecLocally(opts) {
|
|
|
123
234
|
const { command, args: base } = playwrightBase(cwd);
|
|
124
235
|
const args = [...base, `--config=${configPath}`, "--reporter=json"];
|
|
125
236
|
opts.onProgress?.(`Running ${command} ${base.join(" ")} on ${opts.testName}…`);
|
|
237
|
+
await opts.setup?.(runDir);
|
|
238
|
+
const stop = opts.during?.(runDir);
|
|
126
239
|
const env = { ...process.env, PLAYWRIGHT_JSON_OUTPUT_NAME: reportPath };
|
|
127
|
-
|
|
240
|
+
// cwd is the RUN dir, not the project: the spec's relative writes (frames/,
|
|
241
|
+
// email-inbox.json, dom-snapshot.html, phase.json) must land where we harvest and
|
|
242
|
+
// clean up, exactly as the cloud runner keys them to its per-run workdir.
|
|
243
|
+
let result;
|
|
244
|
+
try {
|
|
245
|
+
result = await runProcess(command, args, runDir, env);
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
stop?.();
|
|
249
|
+
}
|
|
128
250
|
if (looksLikePlaywrightMissing(result)) {
|
|
129
251
|
cleanup();
|
|
130
252
|
throw new PlaywrightMissingError(PLAYWRIGHT_INSTALL_HINT);
|
|
@@ -148,14 +270,25 @@ export async function runSpecLocally(opts) {
|
|
|
148
270
|
cleanup();
|
|
149
271
|
throw new Error(`Playwright ran no tests from the rendered spec.\n${detail}`);
|
|
150
272
|
}
|
|
273
|
+
const harvested = opts.harvest ? harvestArtifacts(runDir, artifactsDir) : undefined;
|
|
151
274
|
let keptSpec = specPath;
|
|
152
275
|
let keptReport = reportPath;
|
|
153
276
|
if (opts.dir) {
|
|
154
|
-
// Persist the exact spec + report next to the artifacts before the run dir is removed
|
|
277
|
+
// Persist the exact spec + report next to the artifacts before the run dir is removed,
|
|
278
|
+
// plus the spec's relative writes (filmstrip frames, failure DOM) that live in it.
|
|
279
|
+
// Text files are scrubbed like the uploaded bytes; frames are pixels.
|
|
155
280
|
keptSpec = path.join(outDir, "beryl-local.spec.ts");
|
|
156
281
|
keptReport = path.join(outDir, "report.json");
|
|
157
282
|
fs.copyFileSync(specPath, keptSpec);
|
|
158
|
-
|
|
283
|
+
copyTextScrubbed(reportPath, keptReport, opts.redact);
|
|
284
|
+
const framesSrc = path.join(runDir, "frames");
|
|
285
|
+
if (fs.existsSync(framesSrc) && framesSrc !== path.join(outDir, "frames")) {
|
|
286
|
+
fs.cpSync(framesSrc, path.join(outDir, "frames"), { recursive: true, force: true });
|
|
287
|
+
}
|
|
288
|
+
const domSrc = path.join(runDir, "dom-snapshot.html");
|
|
289
|
+
if (fs.existsSync(domSrc) && domSrc !== path.join(outDir, "dom-snapshot.html")) {
|
|
290
|
+
copyTextScrubbed(domSrc, path.join(outDir, "dom-snapshot.html"), opts.redact);
|
|
291
|
+
}
|
|
159
292
|
}
|
|
160
293
|
else {
|
|
161
294
|
// Without --dir the artifacts lived in the run dir we're about to delete, so their paths
|
|
@@ -165,5 +298,13 @@ export async function runSpecLocally(opts) {
|
|
|
165
298
|
}
|
|
166
299
|
cleanup();
|
|
167
300
|
const { passed, failed } = tally(results);
|
|
168
|
-
return {
|
|
301
|
+
return {
|
|
302
|
+
passed,
|
|
303
|
+
failed,
|
|
304
|
+
results,
|
|
305
|
+
directory: outDir,
|
|
306
|
+
spec: keptSpec,
|
|
307
|
+
report: keptReport,
|
|
308
|
+
...(harvested ? { harvested } : {}),
|
|
309
|
+
};
|
|
169
310
|
}
|
package/dist/output.js
CHANGED
|
@@ -16,6 +16,25 @@ export function statusColor(status) {
|
|
|
16
16
|
return yellow(status);
|
|
17
17
|
return status;
|
|
18
18
|
}
|
|
19
|
+
// Humans read "2h ago", not "2026-07-28T09:51:45.921815Z". Past a month the
|
|
20
|
+
// relative form stops being informative, so it falls back to the plain date.
|
|
21
|
+
export function timeAgo(value) {
|
|
22
|
+
if (!value)
|
|
23
|
+
return "";
|
|
24
|
+
const t = new Date(String(value)).getTime();
|
|
25
|
+
if (Number.isNaN(t))
|
|
26
|
+
return "";
|
|
27
|
+
const s = Math.max(0, (Date.now() - t) / 1000);
|
|
28
|
+
if (s < 60)
|
|
29
|
+
return "just now";
|
|
30
|
+
if (s < 3600)
|
|
31
|
+
return `${Math.floor(s / 60)}m ago`;
|
|
32
|
+
if (s < 86400)
|
|
33
|
+
return `${Math.floor(s / 3600)}h ago`;
|
|
34
|
+
if (s < 30 * 86400)
|
|
35
|
+
return `${Math.floor(s / 86400)}d ago`;
|
|
36
|
+
return new Date(t).toISOString().slice(0, 10);
|
|
37
|
+
}
|
|
19
38
|
const MAX_CELL = 60;
|
|
20
39
|
function cell(value) {
|
|
21
40
|
if (value === null || value === undefined)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
2
3
|
import { createRequire } from "node:module";
|
|
4
|
+
import os from "node:os";
|
|
3
5
|
import path from "node:path";
|
|
4
6
|
// The two commands that turn "nothing Playwright-related installed" into "local runs work":
|
|
5
7
|
// the test runner as a dev dep, then its browser binary. Kept as data so the CLI can both
|
|
@@ -7,24 +9,133 @@ import path from "node:path";
|
|
|
7
9
|
export const INSTALL_TEST_RUNNER = ["npm", "i", "-D", "@playwright/test"];
|
|
8
10
|
export const INSTALL_CHROMIUM = ["npx", "playwright", "install", "chromium"];
|
|
9
11
|
export const PLAYWRIGHT_INSTALL_COMMANDS = `${INSTALL_TEST_RUNNER.join(" ")} && ${INSTALL_CHROMIUM.join(" ")}`;
|
|
10
|
-
// Resolve
|
|
11
|
-
//
|
|
12
|
-
|
|
12
|
+
// Resolve from the project tree, not from wherever the globally-installed CLI happens to
|
|
13
|
+
// live — `createRequire` rooted at cwd walks up the same node_modules chain Playwright will.
|
|
14
|
+
const projectRequire = (cwd) => createRequire(path.join(cwd, "package.json"));
|
|
13
15
|
export function hasPlaywrightTest(cwd) {
|
|
14
16
|
try {
|
|
15
|
-
|
|
17
|
+
projectRequire(cwd).resolve("@playwright/test");
|
|
16
18
|
return true;
|
|
17
19
|
}
|
|
18
20
|
catch {
|
|
19
21
|
return false;
|
|
20
22
|
}
|
|
21
23
|
}
|
|
22
|
-
|
|
24
|
+
// A local run launches chromium headless, which Playwright serves from a separate
|
|
25
|
+
// `chromium_headless_shell` build (`playwright install chromium` fetches both). That is the
|
|
26
|
+
// only engine a run ever launches, so it is the only one we install or check for.
|
|
27
|
+
const HEADLESS_SHELL = "chromium-headless-shell";
|
|
28
|
+
// Written last by Playwright's downloader, so its presence means a complete browser.
|
|
29
|
+
const INSTALL_MARKER = "INSTALLATION_COMPLETE";
|
|
30
|
+
function playwrightCoreDir(cwd) {
|
|
31
|
+
const req = projectRequire(cwd);
|
|
32
|
+
try {
|
|
33
|
+
return path.dirname(req.resolve("playwright-core/package.json"));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Not hoisted (pnpm) — look from @playwright/test's own tree instead.
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const fromRunner = createRequire(req.resolve("@playwright/test"));
|
|
40
|
+
return path.dirname(fromRunner.resolve("playwright-core/package.json"));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Mirrors playwright-core's registry root: PLAYWRIGHT_BROWSERS_PATH ("0" means inside the
|
|
47
|
+
// package), else the per-platform cache dir. undefined on a platform Playwright doesn't
|
|
48
|
+
// support, where we have no verdict to offer.
|
|
49
|
+
function browsersRoot(coreDir) {
|
|
50
|
+
const override = process.env.PLAYWRIGHT_BROWSERS_PATH;
|
|
51
|
+
if (override === "0")
|
|
52
|
+
return coreDir ? path.join(coreDir, ".local-browsers") : undefined;
|
|
53
|
+
if (override)
|
|
54
|
+
return path.resolve(override);
|
|
55
|
+
const home = os.homedir();
|
|
56
|
+
if (process.platform === "darwin")
|
|
57
|
+
return path.join(home, "Library", "Caches", "ms-playwright");
|
|
58
|
+
if (process.platform === "win32") {
|
|
59
|
+
const local = process.env.LOCALAPPDATA || path.join(home, "AppData", "Local");
|
|
60
|
+
return path.join(local, "ms-playwright");
|
|
61
|
+
}
|
|
62
|
+
if (process.platform === "linux") {
|
|
63
|
+
return path.join(process.env.XDG_CACHE_HOME || path.join(home, ".cache"), "ms-playwright");
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
// The exact directory this project's Playwright will look in: <root>/<browser name with
|
|
68
|
+
// dashes as underscores>-<revision>, straight out of playwright-core's browsers.json — so a
|
|
69
|
+
// browser downloaded for an older Playwright doesn't read as the one this one needs.
|
|
70
|
+
function wantedChromiumDir(coreDir) {
|
|
71
|
+
if (!coreDir)
|
|
72
|
+
return undefined;
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(coreDir, "browsers.json"), "utf8"));
|
|
75
|
+
const entry = parsed.browsers?.find((b) => b.name === HEADLESS_SHELL) ??
|
|
76
|
+
parsed.browsers?.find((b) => b.name === "chromium");
|
|
77
|
+
if (!entry?.name || !entry.revision)
|
|
78
|
+
return undefined;
|
|
79
|
+
return `${entry.name.replace(/-/g, "_")}-${entry.revision}`;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const isComplete = (dir) => fs.existsSync(path.join(dir, INSTALL_MARKER));
|
|
86
|
+
/**
|
|
87
|
+
* Is the browser binary a local run actually launches present? Without it every test in a
|
|
88
|
+
* run dies with the same `browserType.launch: Executable doesn't exist` — an environment
|
|
89
|
+
* problem that reads as a broken suite.
|
|
90
|
+
*/
|
|
91
|
+
export function hasChromiumBrowser(cwd) {
|
|
92
|
+
const coreDir = playwrightCoreDir(cwd);
|
|
93
|
+
const root = browsersRoot(coreDir);
|
|
94
|
+
// Nowhere known to look — say nothing rather than block; the run surfaces Playwright's
|
|
95
|
+
// own error if it really is missing.
|
|
96
|
+
if (!root)
|
|
97
|
+
return true;
|
|
98
|
+
const wanted = wantedChromiumDir(coreDir);
|
|
99
|
+
if (wanted)
|
|
100
|
+
return isComplete(path.join(root, wanted));
|
|
101
|
+
// Revision unknown (no resolvable playwright-core): any completed chromium build is the
|
|
102
|
+
// best evidence there is, and guessing wrong only costs an idempotent re-install.
|
|
103
|
+
try {
|
|
104
|
+
return fs
|
|
105
|
+
.readdirSync(root)
|
|
106
|
+
.some((d) => /^chromium(_headless_shell)?-\d+$/.test(d) && isComplete(path.join(root, d)));
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export function playwrightGaps(cwd) {
|
|
113
|
+
const runner = !hasPlaywrightTest(cwd);
|
|
114
|
+
// With no runner there is no revision to judge a cached browser against, so the honest
|
|
115
|
+
// answer is the full install — not the npm half and a second failure right after it.
|
|
116
|
+
return { runner, browser: runner || !hasChromiumBrowser(cwd) };
|
|
117
|
+
}
|
|
118
|
+
export const anyGap = (gaps) => gaps.runner || gaps.browser;
|
|
119
|
+
export function describeGaps(gaps) {
|
|
120
|
+
if (gaps.runner && gaps.browser)
|
|
121
|
+
return "@playwright/test + the Chromium browser";
|
|
122
|
+
return gaps.browser ? "the Chromium browser" : "@playwright/test";
|
|
123
|
+
}
|
|
124
|
+
/** Only the commands the missing halves need — a present @playwright/test isn't reinstalled. */
|
|
125
|
+
export function installCommandsFor(gaps) {
|
|
126
|
+
const commands = [
|
|
127
|
+
...(gaps.runner ? [INSTALL_TEST_RUNNER.join(" ")] : []),
|
|
128
|
+
...(gaps.browser ? [INSTALL_CHROMIUM.join(" ")] : []),
|
|
129
|
+
];
|
|
130
|
+
return commands.length > 0 ? commands.join(" && ") : PLAYWRIGHT_INSTALL_COMMANDS;
|
|
131
|
+
}
|
|
132
|
+
export const installPrompt = (commands) => `Install local Playwright now (${commands})? [Y/n] `;
|
|
133
|
+
export const INSTALL_PROMPT = installPrompt(PLAYWRIGHT_INSTALL_COMMANDS);
|
|
23
134
|
// Ask (default-yes) whether to install. Returns false — not throwing — when there is no answer
|
|
24
135
|
// or the prompt fails, so callers uniformly fall back to printing the install hint.
|
|
25
|
-
export async function confirmInstall(prompt) {
|
|
136
|
+
export async function confirmInstall(prompt, commands = PLAYWRIGHT_INSTALL_COMMANDS) {
|
|
26
137
|
try {
|
|
27
|
-
return !/^n(o)?$/i.test(await prompt(
|
|
138
|
+
return !/^n(o)?$/i.test(await prompt(installPrompt(commands)));
|
|
28
139
|
}
|
|
29
140
|
catch {
|
|
30
141
|
return false;
|
package/dist/progress.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { dim } from "./output.js";
|
|
2
|
+
const BAR_WIDTH = 18;
|
|
3
|
+
export function renderProgressLine(state) {
|
|
4
|
+
const stepFraction = state.stepTotal && state.stepTotal > 0
|
|
5
|
+
? Math.min(1, (state.step ?? 0) / state.stepTotal)
|
|
6
|
+
: 0;
|
|
7
|
+
const fraction = state.total > 0 ? Math.min(1, (state.done + stepFraction) / state.total) : 0;
|
|
8
|
+
const filled = Math.round(fraction * BAR_WIDTH);
|
|
9
|
+
const bar = "█".repeat(filled) + "░".repeat(BAR_WIDTH - filled);
|
|
10
|
+
const pct = `${Math.round(fraction * 100)}%`.padStart(4);
|
|
11
|
+
const steps = state.stepTotal && state.stepTotal > 0
|
|
12
|
+
? ` · step ${Math.min(state.step ?? 0, state.stepTotal)}/${state.stepTotal}`
|
|
13
|
+
: "";
|
|
14
|
+
const tally = state.failed > 0 ? ` · ✓${state.passed} ✗${state.failed}` : ` · ✓${state.passed}`;
|
|
15
|
+
return `[${bar}]${pct} · test ${Math.min(state.done + 1, state.total)}/${state.total}${tally} · ${state.title}${steps}`;
|
|
16
|
+
}
|
|
17
|
+
/** A single rewriting status line on a TTY; quiet elsewhere (the caller prints
|
|
18
|
+
* per-test boundary lines instead, so MCP/CI logs stay readable). */
|
|
19
|
+
export class ProgressBar {
|
|
20
|
+
stream;
|
|
21
|
+
lastLen = 0;
|
|
22
|
+
tty;
|
|
23
|
+
constructor(stream = process.stderr) {
|
|
24
|
+
this.stream = stream;
|
|
25
|
+
this.tty = Boolean(stream.isTTY);
|
|
26
|
+
}
|
|
27
|
+
get active() {
|
|
28
|
+
return this.tty;
|
|
29
|
+
}
|
|
30
|
+
update(state) {
|
|
31
|
+
if (!this.tty)
|
|
32
|
+
return;
|
|
33
|
+
const line = renderProgressLine(state);
|
|
34
|
+
const padded = line.padEnd(this.lastLen);
|
|
35
|
+
this.lastLen = line.length;
|
|
36
|
+
this.stream.write(`\r${dim(padded)}`);
|
|
37
|
+
}
|
|
38
|
+
clear() {
|
|
39
|
+
if (!this.tty || this.lastLen === 0)
|
|
40
|
+
return;
|
|
41
|
+
this.stream.write(`\r${" ".repeat(this.lastLen)}\r`);
|
|
42
|
+
this.lastLen = 0;
|
|
43
|
+
}
|
|
44
|
+
}
|
package/dist/registry/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { accountCommands } from "../commands/account.js";
|
|
2
|
+
import { testAccountCommands } from "../commands/accounts.js";
|
|
2
3
|
import { authCommands } from "../commands/auth.js";
|
|
3
4
|
import { configCommands } from "../commands/config-vars.js";
|
|
4
|
-
import { credentialCommands } from "../commands/credentials.js";
|
|
5
5
|
import { environmentCommands } from "../commands/environments.js";
|
|
6
6
|
import { explorationCommands } from "../commands/explorations.js";
|
|
7
|
-
import {
|
|
7
|
+
import { mailboxCommands } from "../commands/mailboxes.js";
|
|
8
8
|
import { initCommands } from "../commands/init.js";
|
|
9
9
|
import { mcpCommands } from "../commands/mcp.js";
|
|
10
10
|
import { projectCommands } from "../commands/projects.js";
|
|
@@ -62,8 +62,8 @@ export const commands = [
|
|
|
62
62
|
...explorationCommands,
|
|
63
63
|
...configCommands,
|
|
64
64
|
...slackCommands,
|
|
65
|
-
...
|
|
66
|
-
...
|
|
65
|
+
...mailboxCommands,
|
|
66
|
+
...testAccountCommands,
|
|
67
67
|
...accountCommands,
|
|
68
68
|
...mcpCommands,
|
|
69
69
|
].map(withScopeFlags).map(hideFromMcp);
|