@sdods/cli 0.2.0
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/LICENSE +17 -0
- package/bin/sdods.js +19 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +11 -0
- package/dist/commands/agent.d.ts +3 -0
- package/dist/commands/agent.js +219 -0
- package/dist/commands/analyze.d.ts +20 -0
- package/dist/commands/analyze.js +216 -0
- package/dist/commands/auth.d.ts +7 -0
- package/dist/commands/auth.js +97 -0
- package/dist/commands/browsers.d.ts +17 -0
- package/dist/commands/browsers.js +103 -0
- package/dist/commands/completion.d.ts +17 -0
- package/dist/commands/completion.js +143 -0
- package/dist/commands/config.d.ts +3 -0
- package/dist/commands/config.js +79 -0
- package/dist/commands/coverage.d.ts +3 -0
- package/dist/commands/coverage.js +65 -0
- package/dist/commands/data.d.ts +7 -0
- package/dist/commands/data.js +188 -0
- package/dist/commands/db.d.ts +3 -0
- package/dist/commands/db.js +241 -0
- package/dist/commands/doctor.d.ts +3 -0
- package/dist/commands/doctor.js +323 -0
- package/dist/commands/env.d.ts +3 -0
- package/dist/commands/env.js +65 -0
- package/dist/commands/features.d.ts +3 -0
- package/dist/commands/features.js +57 -0
- package/dist/commands/feedback.d.ts +7 -0
- package/dist/commands/feedback.js +96 -0
- package/dist/commands/har.d.ts +3 -0
- package/dist/commands/har.js +124 -0
- package/dist/commands/heal.d.ts +4 -0
- package/dist/commands/heal.js +63 -0
- package/dist/commands/init.d.ts +38 -0
- package/dist/commands/init.js +363 -0
- package/dist/commands/insights.d.ts +7 -0
- package/dist/commands/insights.js +127 -0
- package/dist/commands/integrations.d.ts +14 -0
- package/dist/commands/integrations.js +306 -0
- package/dist/commands/lint.d.ts +3 -0
- package/dist/commands/lint.js +74 -0
- package/dist/commands/mcp.d.ts +3 -0
- package/dist/commands/mcp.js +190 -0
- package/dist/commands/project.d.ts +3 -0
- package/dist/commands/project.js +133 -0
- package/dist/commands/proposals.d.ts +3 -0
- package/dist/commands/proposals.js +106 -0
- package/dist/commands/record.d.ts +3 -0
- package/dist/commands/record.js +174 -0
- package/dist/commands/report.d.ts +3 -0
- package/dist/commands/report.js +255 -0
- package/dist/commands/run.d.ts +41 -0
- package/dist/commands/run.js +369 -0
- package/dist/commands/schedule.d.ts +3 -0
- package/dist/commands/schedule.js +337 -0
- package/dist/commands/serve.d.ts +3 -0
- package/dist/commands/serve.js +38 -0
- package/dist/commands/steps.d.ts +8 -0
- package/dist/commands/steps.js +67 -0
- package/dist/commands/tokens.d.ts +3 -0
- package/dist/commands/tokens.js +110 -0
- package/dist/commands/trace.d.ts +10 -0
- package/dist/commands/trace.js +104 -0
- package/dist/commands/upgrade.d.ts +14 -0
- package/dist/commands/upgrade.js +115 -0
- package/dist/commands/users.d.ts +3 -0
- package/dist/commands/users.js +183 -0
- package/dist/commands/watch.d.ts +7 -0
- package/dist/commands/watch.js +116 -0
- package/dist/commands/workspace.d.ts +8 -0
- package/dist/commands/workspace.js +83 -0
- package/dist/context.d.ts +17 -0
- package/dist/context.js +22 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/program.d.ts +7 -0
- package/dist/program.js +90 -0
- package/dist/templates/project.d.ts +10 -0
- package/dist/templates/project.js +115 -0
- package/dist/ui.d.ts +16 -0
- package/dist/ui.js +67 -0
- package/package.json +61 -0
- package/templates/.claude/skills/sdods-record/SKILL.md +75 -0
- package/templates/.claude/skills/sdods-run/SKILL.md +54 -0
- package/templates/.env.example +62 -0
- package/templates/docker-compose.yml +40 -0
- package/templates/projects/demo-shop/.env.example +4 -0
- package/templates/projects/demo-shop/data/common/posts.yaml +11 -0
- package/templates/projects/demo-shop/data/common/products.json +8 -0
- package/templates/projects/demo-shop/data/common/users.csv +7 -0
- package/templates/projects/demo-shop/data/factories.ts +16 -0
- package/templates/projects/demo-shop/data/staging/users.csv +6 -0
- package/templates/projects/demo-shop/envs/local.yaml +17 -0
- package/templates/projects/demo-shop/envs/staging.yaml +23 -0
- package/templates/projects/demo-shop/features/__screenshots__/demo-shop--ui--chromium/darwin/inventory.png +0 -0
- package/templates/projects/demo-shop/features/api/posts.feature +90 -0
- package/templates/projects/demo-shop/features/auth/data-driven-login.feature +17 -0
- package/templates/projects/demo-shop/features/auth/login.feature +32 -0
- package/templates/projects/demo-shop/features/auth/user-pool.feature +20 -0
- package/templates/projects/demo-shop/features/cart/cart.feature +33 -0
- package/templates/projects/demo-shop/features/hybrid/seed-then-verify.feature +28 -0
- package/templates/projects/demo-shop/features/inventory/inventory.feature +38 -0
- package/templates/projects/demo-shop/features/inventory/network-intercept.feature +29 -0
- package/templates/projects/demo-shop/features/inventory/visual.feature +10 -0
- package/templates/projects/demo-shop/har/staging/cart.har +1 -0
- package/templates/projects/demo-shop/har/staging/cart.har.json +11 -0
- package/templates/projects/demo-shop/har/staging/login.har +1 -0
- package/templates/projects/demo-shop/har/staging/login.har.json +11 -0
- package/templates/projects/demo-shop/har/staging/posts.api.har +2061 -0
- package/templates/projects/demo-shop/har/staging/posts.har.json +8 -0
- package/templates/projects/demo-shop/har/staging/products.har +1 -0
- package/templates/projects/demo-shop/har/staging/products.har.json +11 -0
- package/templates/projects/demo-shop/har/staging/site.api.har +148 -0
- package/templates/projects/demo-shop/har/staging/site.har +1 -0
- package/templates/projects/demo-shop/har/staging/site.har.json +11 -0
- package/templates/projects/demo-shop/pages/CartPage.ts +20 -0
- package/templates/projects/demo-shop/pages/InventoryPage.ts +103 -0
- package/templates/projects/demo-shop/pages/LoginPage.ts +42 -0
- package/templates/projects/demo-shop/schemas/post.schema.json +13 -0
- package/templates/projects/demo-shop/sdods.project.yaml +185 -0
- package/templates/projects/demo-shop/steps/auth.ts +7 -0
- package/templates/projects/demo-shop/steps/fixtures.ts +39 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { execa } from 'execa';
|
|
4
|
+
import pc from 'picocolors';
|
|
5
|
+
import { runFiles } from '@sdods/contracts';
|
|
6
|
+
import { SdodsError } from '@sdods/core';
|
|
7
|
+
import { createContext } from '../context.js';
|
|
8
|
+
import { json, ok, out, table } from '../ui.js';
|
|
9
|
+
function artifactsRoot(rootDir) {
|
|
10
|
+
return resolve(rootDir, process.env.SDODS_ARTIFACTS_DIR ?? '.sdods/runs');
|
|
11
|
+
}
|
|
12
|
+
function latestRunId(root) {
|
|
13
|
+
if (!existsSync(root))
|
|
14
|
+
return null;
|
|
15
|
+
const dirs = readdirSync(root)
|
|
16
|
+
.map((name) => ({ name, dir: join(root, name) }))
|
|
17
|
+
.filter((d) => statSync(d.dir).isDirectory() &&
|
|
18
|
+
(existsSync(join(d.dir, runFiles.manifest)) ||
|
|
19
|
+
existsSync(join(d.dir, runFiles.messages)) ||
|
|
20
|
+
existsSync(join(d.dir, runFiles.results))))
|
|
21
|
+
.sort((a, b) => statSync(b.dir).mtimeMs - statSync(a.dir).mtimeMs);
|
|
22
|
+
return dirs[0]?.name ?? null;
|
|
23
|
+
}
|
|
24
|
+
function readJsonFile(file) {
|
|
25
|
+
if (!existsSync(file))
|
|
26
|
+
return null;
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async function openPath(p) {
|
|
35
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
36
|
+
await execa(cmd, [p], { shell: process.platform === 'win32' }).catch(() => undefined);
|
|
37
|
+
}
|
|
38
|
+
export function register(program) {
|
|
39
|
+
const report = program
|
|
40
|
+
.command('report')
|
|
41
|
+
.description('Open reports of a run, ingest results into the database, render dashboards')
|
|
42
|
+
.option('--last', 'use the most recent run')
|
|
43
|
+
.option('--run <id>', 'run id')
|
|
44
|
+
.option('--open', 'open the HTML report and dashboard')
|
|
45
|
+
.option('--html', 'open only the HTML report')
|
|
46
|
+
.option('--dashboard', 'open only the SDODS dashboard')
|
|
47
|
+
.action(async (opts, cmd) => {
|
|
48
|
+
const ctx = createContext(cmd);
|
|
49
|
+
const root = artifactsRoot(ctx.rootDir);
|
|
50
|
+
const runId = opts.run ?? latestRunId(root);
|
|
51
|
+
if (!runId)
|
|
52
|
+
throw new SdodsError('RUN_FAILED', `No runs found under ${root}.`, {
|
|
53
|
+
hint: 'Run `sdods run -p <slug>` first.',
|
|
54
|
+
exitCode: 2,
|
|
55
|
+
});
|
|
56
|
+
const dir = join(root, runId);
|
|
57
|
+
const manifest = readJsonFile(join(dir, runFiles.manifest));
|
|
58
|
+
const summary = readJsonFile(join(dir, runFiles.summary));
|
|
59
|
+
const html = join(dir, runFiles.htmlReport, 'index.html');
|
|
60
|
+
const dashboard = join(dir, runFiles.dashboard, 'index.html');
|
|
61
|
+
const info = {
|
|
62
|
+
runId,
|
|
63
|
+
dir,
|
|
64
|
+
manifest,
|
|
65
|
+
summary,
|
|
66
|
+
reports: {
|
|
67
|
+
html: existsSync(html) ? html : null,
|
|
68
|
+
dashboard: existsSync(dashboard) ? dashboard : null,
|
|
69
|
+
messages: existsSync(join(dir, runFiles.messages)) ? join(dir, runFiles.messages) : null,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
if (ctx.opts.json)
|
|
73
|
+
return json(info);
|
|
74
|
+
out(pc.bold(`Run ${runId}`) + pc.dim(` ${dir}`));
|
|
75
|
+
if (manifest)
|
|
76
|
+
out(pc.dim(`project ${manifest.projectSlug} · env ${manifest.env} · ${manifest.startedAt}`));
|
|
77
|
+
if (summary?.totals) {
|
|
78
|
+
const t = summary.totals;
|
|
79
|
+
out(`${pc.green(`${t.passed ?? 0} passed`)} ${pc.red(`${t.failed ?? 0} failed`)} ${pc.yellow(`${t.flaky ?? 0} flaky`)} ${pc.dim(`${t.skipped ?? 0} skipped`)}`);
|
|
80
|
+
}
|
|
81
|
+
table(Object.entries(info.reports).map(([k, v]) => ({
|
|
82
|
+
report: k,
|
|
83
|
+
path: v ?? pc.dim('(missing)'),
|
|
84
|
+
})));
|
|
85
|
+
if (opts.open || opts.html)
|
|
86
|
+
if (info.reports.html)
|
|
87
|
+
await openPath(info.reports.html);
|
|
88
|
+
if (opts.open || opts.dashboard)
|
|
89
|
+
if (info.reports.dashboard)
|
|
90
|
+
await openPath(info.reports.dashboard);
|
|
91
|
+
});
|
|
92
|
+
report
|
|
93
|
+
.command('ingest [files...]')
|
|
94
|
+
.description('Ingest cucumber messages NDJSON and runner JSON results into the database')
|
|
95
|
+
.requiredOption('--run-id <id>', 'run id (directory name under the artifacts root)')
|
|
96
|
+
.option('-p, --project <slug>', 'project slug when run.json is missing')
|
|
97
|
+
.option('--manifest <file>', 'explicit run.json path')
|
|
98
|
+
.option('--format <fmt>', 'auto|cucumber|runner-json (alias: pw-json)', 'auto')
|
|
99
|
+
.option('--artifacts-dir <dir>', 'artifacts root (default .sdods/runs)')
|
|
100
|
+
.option('--replace', 'delete previously ingested rows of this run first')
|
|
101
|
+
.option('-e, --env <name>', 'environment name when run.json is missing')
|
|
102
|
+
.option('--server <url>', 'upload to an SDODS server (POST /api/runs/:id/ingest) instead of writing to a local database')
|
|
103
|
+
.option('--token <token>', 'API token for --server (or SDODS_TOKEN)')
|
|
104
|
+
.action(async (files, opts, cmd) => {
|
|
105
|
+
const ctx = createContext(cmd);
|
|
106
|
+
const serverUrl = opts.server ?? process.env.SDODS_SERVER_URL;
|
|
107
|
+
if (serverUrl) {
|
|
108
|
+
// CI path: no DB credentials on the runner; the server ingests with a scoped token.
|
|
109
|
+
const token = opts.token ?? process.env.SDODS_TOKEN;
|
|
110
|
+
if (!token)
|
|
111
|
+
throw new SdodsError('AUTH_FAILED', '--server needs --token (or SDODS_TOKEN).', {
|
|
112
|
+
exitCode: 2,
|
|
113
|
+
});
|
|
114
|
+
const { readFileSync, existsSync } = await import('node:fs');
|
|
115
|
+
const { basename } = await import('node:path');
|
|
116
|
+
const form = new FormData();
|
|
117
|
+
let count = 0;
|
|
118
|
+
for (const f of files) {
|
|
119
|
+
const abs = resolve(ctx.rootDir, f);
|
|
120
|
+
if (!existsSync(abs))
|
|
121
|
+
continue;
|
|
122
|
+
form.append('files', new Blob([readFileSync(abs)]), basename(abs));
|
|
123
|
+
count++;
|
|
124
|
+
}
|
|
125
|
+
if (!count)
|
|
126
|
+
throw new SdodsError('CONFIG_INVALID', 'No existing files to upload.', { exitCode: 2 });
|
|
127
|
+
const url = `${serverUrl.replace(/\/$/, '')}/api/runs/${encodeURIComponent(opts.runId)}/ingest`;
|
|
128
|
+
const res = await fetch(url, {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
headers: { authorization: `Bearer ${token}` },
|
|
131
|
+
body: form,
|
|
132
|
+
});
|
|
133
|
+
const body = (await res.json().catch(() => ({})));
|
|
134
|
+
if (!res.ok)
|
|
135
|
+
throw new SdodsError('RUN_FAILED', `Server ingest failed (${res.status}): ${JSON.stringify(body)}`, {
|
|
136
|
+
exitCode: 1,
|
|
137
|
+
});
|
|
138
|
+
if (ctx.opts.json)
|
|
139
|
+
return json(body);
|
|
140
|
+
return ok(`Uploaded ${count} file(s) for run ${opts.runId} to ${serverUrl}`);
|
|
141
|
+
}
|
|
142
|
+
const m = await import('@sdods/db');
|
|
143
|
+
const adb = m.createDb();
|
|
144
|
+
try {
|
|
145
|
+
await m.migrateToLatest(adb);
|
|
146
|
+
const root = opts.artifactsDir
|
|
147
|
+
? resolve(ctx.rootDir, opts.artifactsDir)
|
|
148
|
+
: artifactsRoot(ctx.rootDir);
|
|
149
|
+
const ndjson = [];
|
|
150
|
+
const runnerJson = [];
|
|
151
|
+
for (const f of files) {
|
|
152
|
+
const abs = resolve(ctx.rootDir, f);
|
|
153
|
+
const fmt = opts.format === 'auto'
|
|
154
|
+
? abs.endsWith('.ndjson')
|
|
155
|
+
? 'cucumber'
|
|
156
|
+
: 'runner-json'
|
|
157
|
+
: // `pw-json` is the pre-1.0 name, still accepted
|
|
158
|
+
opts.format === 'pw-json'
|
|
159
|
+
? 'runner-json'
|
|
160
|
+
: opts.format;
|
|
161
|
+
(fmt === 'cucumber' ? ndjson : runnerJson).push(abs);
|
|
162
|
+
}
|
|
163
|
+
const manifest = opts.manifest
|
|
164
|
+
? readJsonFile(resolve(ctx.rootDir, opts.manifest))
|
|
165
|
+
: undefined;
|
|
166
|
+
const res = await m.ingestRun(adb, {
|
|
167
|
+
runId: opts.runId,
|
|
168
|
+
projectSlug: opts.project,
|
|
169
|
+
manifest,
|
|
170
|
+
ndjsonPaths: ndjson,
|
|
171
|
+
runnerJsonPaths: runnerJson,
|
|
172
|
+
artifactsRoot: root,
|
|
173
|
+
replace: Boolean(opts.replace),
|
|
174
|
+
env: opts.env,
|
|
175
|
+
});
|
|
176
|
+
if (ctx.opts.json)
|
|
177
|
+
return json(res);
|
|
178
|
+
ok(`Ingested run ${res.runId} (${res.projectSlug}): ${res.scenarios} scenario(s), ${res.attempts} attempt(s), ${res.steps} step(s), ${res.artifacts} artifact(s), ${res.healEvents} heal event(s)`);
|
|
179
|
+
const t = res.totals;
|
|
180
|
+
out(`${pc.green(`${t.passed} passed`)} ${pc.red(`${t.failed} failed`)} ${pc.yellow(`${t.flaky} flaky`)} ${pc.dim(`${t.skipped} skipped`)} → ${res.status}`);
|
|
181
|
+
if (res.filesSkipped.length)
|
|
182
|
+
out(pc.dim(`skipped (already ingested): ${res.filesSkipped.join(', ')}`));
|
|
183
|
+
if (res.parseErrors)
|
|
184
|
+
out(pc.yellow(`${res.parseErrors} unparsable line(s) skipped`));
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
await adb.close();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
report
|
|
191
|
+
.command('merge <dirs...>')
|
|
192
|
+
.description('Merge sharded run reports into one HTML report and JUnit file')
|
|
193
|
+
.option('--run <id>', 'run id to write the merged report into (default: latest)')
|
|
194
|
+
.option('--reporter <list>', 'reporters for the merged output', 'html,junit')
|
|
195
|
+
.action(async (dirs, opts, cmd) => {
|
|
196
|
+
const ctx = createContext(cmd);
|
|
197
|
+
const root = artifactsRoot(ctx.rootDir);
|
|
198
|
+
const runId = opts.run ?? latestRunId(root);
|
|
199
|
+
if (!runId) {
|
|
200
|
+
throw new SdodsError('CONFIG_NOT_FOUND', 'No run to merge into.', {
|
|
201
|
+
hint: 'Pass --run <id>, or run a suite first.',
|
|
202
|
+
exitCode: 2,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
const runDir = join(root, runId);
|
|
206
|
+
const missing = dirs.filter((d) => !existsSync(resolve(ctx.rootDir, d)));
|
|
207
|
+
if (missing.length) {
|
|
208
|
+
throw new SdodsError('CONFIG_NOT_FOUND', `No such shard report directory: ${missing.join(', ')}`, {
|
|
209
|
+
hint: 'Each argument is a directory of shard reports downloaded from CI.',
|
|
210
|
+
exitCode: 2,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
// Shard reports are produced by `sdods run --reporter blob --shard i/n`; merging them
|
|
214
|
+
// rebuilds one HTML report and JUnit file for the whole matrix.
|
|
215
|
+
const args = [
|
|
216
|
+
'playwright',
|
|
217
|
+
'merge-reports',
|
|
218
|
+
'--reporter',
|
|
219
|
+
opts.reporter,
|
|
220
|
+
...dirs.map((d) => resolve(ctx.rootDir, d)),
|
|
221
|
+
];
|
|
222
|
+
const res = await execa('npx', args, {
|
|
223
|
+
cwd: ctx.rootDir,
|
|
224
|
+
reject: false,
|
|
225
|
+
env: { ...process.env, PLAYWRIGHT_HTML_OUTPUT_DIR: join(runDir, runFiles.htmlReport) },
|
|
226
|
+
});
|
|
227
|
+
if (res.exitCode !== 0) {
|
|
228
|
+
throw new SdodsError('RUN_FAILED', `Merging shard reports failed (exit ${res.exitCode}).`, {
|
|
229
|
+
hint: res.stderr?.split('\n').slice(-3).join(' ') || undefined,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
if (ctx.opts.json)
|
|
233
|
+
return json({ runId, merged: dirs.length, htmlReport: join(runDir, runFiles.htmlReport) });
|
|
234
|
+
ok(`Merged ${dirs.length} shard report dir(s) into ${join(runDir, runFiles.htmlReport)}`);
|
|
235
|
+
});
|
|
236
|
+
program
|
|
237
|
+
.command('show-report')
|
|
238
|
+
.description('Open the HTML report of a run')
|
|
239
|
+
.option('--run <id>', 'run id (default: latest)')
|
|
240
|
+
.action(async (opts, cmd) => {
|
|
241
|
+
const ctx = createContext(cmd);
|
|
242
|
+
const root = artifactsRoot(ctx.rootDir);
|
|
243
|
+
const runId = opts.run ?? latestRunId(root);
|
|
244
|
+
if (!runId)
|
|
245
|
+
throw new SdodsError('RUN_FAILED', `No runs found under ${root}.`, { exitCode: 2 });
|
|
246
|
+
const html = join(root, runId, runFiles.htmlReport, 'index.html');
|
|
247
|
+
if (!existsSync(html))
|
|
248
|
+
throw new SdodsError('RUN_FAILED', `No HTML report at ${html}.`, { exitCode: 2 });
|
|
249
|
+
await execa('npx', ['playwright', 'show-report', join(root, runId, runFiles.htmlReport)], {
|
|
250
|
+
stdio: 'inherit',
|
|
251
|
+
cwd: ctx.rootDir,
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
//# sourceMappingURL=report.js.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
export interface RunFlags {
|
|
3
|
+
project?: string;
|
|
4
|
+
env?: string;
|
|
5
|
+
tags?: string;
|
|
6
|
+
layer: string[];
|
|
7
|
+
browser: string[];
|
|
8
|
+
module: string[];
|
|
9
|
+
process?: string;
|
|
10
|
+
headed?: boolean;
|
|
11
|
+
workers?: number;
|
|
12
|
+
shard?: string;
|
|
13
|
+
retries?: number;
|
|
14
|
+
grep?: string;
|
|
15
|
+
feature?: string;
|
|
16
|
+
scenario?: string;
|
|
17
|
+
runId?: string;
|
|
18
|
+
artifactsDir?: string;
|
|
19
|
+
lint: boolean;
|
|
20
|
+
ingest?: boolean;
|
|
21
|
+
allure?: boolean;
|
|
22
|
+
harUpdate?: boolean;
|
|
23
|
+
harReplay?: boolean;
|
|
24
|
+
strict?: boolean;
|
|
25
|
+
updateSnapshots?: boolean;
|
|
26
|
+
ui?: boolean;
|
|
27
|
+
debug?: boolean;
|
|
28
|
+
list?: boolean;
|
|
29
|
+
repeatEach?: number;
|
|
30
|
+
failOnFlaky?: boolean;
|
|
31
|
+
maxFailures?: number;
|
|
32
|
+
timeout?: number;
|
|
33
|
+
reporter: string[];
|
|
34
|
+
projectMatrix?: boolean;
|
|
35
|
+
device?: string;
|
|
36
|
+
reporterMode?: string;
|
|
37
|
+
trigger?: string;
|
|
38
|
+
}
|
|
39
|
+
export declare function register(program: Command): void;
|
|
40
|
+
export declare function runCommand(flags: RunFlags, cmd: Command): Promise<number>;
|
|
41
|
+
//# sourceMappingURL=run.d.ts.map
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join, relative, resolve as resolvePath } from 'node:path';
|
|
3
|
+
import { execa } from 'execa';
|
|
4
|
+
import pc from 'picocolors';
|
|
5
|
+
import { newRunId, runFiles, } from '@sdods/contracts';
|
|
6
|
+
import { SdodsError, CLI_OVERRIDES_ENV, VERSION, formatFindings, lintProject, listGeneratedProjects, moduleByName, moduleDir, normalizeTagExpr, serializeCliOverrides, } from '@sdods/core';
|
|
7
|
+
import { createContext } from '../context.js';
|
|
8
|
+
import { collect, json, out, parseIntFlag, warn } from '../ui.js';
|
|
9
|
+
function addRunOptions(cmd) {
|
|
10
|
+
return cmd
|
|
11
|
+
.option('-p, --project <slug>', 'project slug (default: the only project, else required)')
|
|
12
|
+
.option('-e, --env <name>', 'environment name')
|
|
13
|
+
.option('-t, --tags <expr>', 'Cucumber tag expression, e.g. "@smoke and not @mock" (comma list = OR)')
|
|
14
|
+
.option('-l, --layer <layer>', 'ui | api | hybrid | recorded (repeatable)', collect, [])
|
|
15
|
+
.option('-b, --browser <name>', 'chromium | firefox | webkit | mobile-chrome | mobile-safari (repeatable)', collect, [])
|
|
16
|
+
.option('-m, --module <name>', 'restrict to a module (repeatable)', collect, [])
|
|
17
|
+
.option('--process <name>', 'run a named process (recipe) from the project or workspace')
|
|
18
|
+
.option('--project-matrix', 'run every browser declared in the project yaml')
|
|
19
|
+
.option('--device <name>', 'device name for mobile emulation, e.g. "iPhone 15"')
|
|
20
|
+
.option('--headed', 'run headed')
|
|
21
|
+
.option('-w, --workers <n>', 'parallel workers', parseIntFlag('workers'))
|
|
22
|
+
.option('--shard <i/n>', 'shard, e.g. 1/3')
|
|
23
|
+
.option('--retries <n>', 'retries per test', parseIntFlag('retries'))
|
|
24
|
+
.option('--grep <pattern>', 'filter tests by title (regular expression)')
|
|
25
|
+
.option('--feature <path>', 'only this feature file (relative to features/)')
|
|
26
|
+
.option('--scenario <name>', 'only scenarios whose title contains this text')
|
|
27
|
+
.option('--run-id <id>', 'run id (default: uuid v7)')
|
|
28
|
+
.option('--artifacts-dir <dir>', 'artifacts root (default: .sdods/runs)')
|
|
29
|
+
.option('--no-lint', 'skip feature lint before running')
|
|
30
|
+
.option('--ingest', 'ingest results into the database after the run')
|
|
31
|
+
.option('--no-ingest', 'do not ingest even when a database is configured')
|
|
32
|
+
.option('--allure', 'also produce allure-results')
|
|
33
|
+
.option('--har-update', 'record/refresh HAR files for @har scenarios')
|
|
34
|
+
.option('--har-replay', 'replay HAR files for @har scenarios')
|
|
35
|
+
.option('--strict', 'with --har-replay: abort on any request not in the HAR (offline)')
|
|
36
|
+
.option('--update-snapshots', 'update visual baselines')
|
|
37
|
+
.option('--ui', 'interactive UI mode')
|
|
38
|
+
.option('--debug', 'step debugger')
|
|
39
|
+
.option('--list', 'list the run targets and tests that would run')
|
|
40
|
+
.option('--repeat-each <n>', 'repeat each test n times', parseIntFlag('repeat-each'))
|
|
41
|
+
.option('--fail-on-flaky', 'exit 1 when any test is flaky')
|
|
42
|
+
.option('--max-failures <n>', 'stop after n failures', parseIntFlag('max-failures'))
|
|
43
|
+
.option('--timeout <ms>', 'per-test timeout override', parseIntFlag('timeout'))
|
|
44
|
+
.option('--reporter <name>', 'reporter override (repeatable, name=outputFile)', collect, [])
|
|
45
|
+
.option('--reporter-mode <mode>', 'default | server | quiet')
|
|
46
|
+
.option('--trigger <kind>', 'cli | ui | ci | agent | mcp | schedule', 'cli');
|
|
47
|
+
}
|
|
48
|
+
export function register(program) {
|
|
49
|
+
const run = addRunOptions(program
|
|
50
|
+
.command('run')
|
|
51
|
+
.description('Generate BDD specs and run them for a project (alias: test)'));
|
|
52
|
+
run.action(async (flags, cmd) => {
|
|
53
|
+
process.exitCode = await runCommand(flags, cmd);
|
|
54
|
+
});
|
|
55
|
+
const test = addRunOptions(program.command('test').description('Alias of run'));
|
|
56
|
+
test.action(async (flags, cmd) => {
|
|
57
|
+
process.exitCode = await runCommand(flags, cmd);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
export async function runCommand(flags, cmd) {
|
|
61
|
+
const ctx = createContext(cmd);
|
|
62
|
+
const entry = ctx.registry.pick(flags.project);
|
|
63
|
+
const projectCfg = entry.config;
|
|
64
|
+
// process → defaults for flags not given explicitly
|
|
65
|
+
const proc = flags.process ? ctx.registry.processOf(entry.slug, flags.process) : undefined;
|
|
66
|
+
const envName = flags.env ?? proc?.env;
|
|
67
|
+
const tags = normalizeTagExpr(flags.tags ?? proc?.tags);
|
|
68
|
+
const layers = (flags.layer.length ? flags.layer : (proc?.layers ?? []));
|
|
69
|
+
const browsers = (flags.projectMatrix
|
|
70
|
+
? projectCfg.browsers
|
|
71
|
+
: flags.browser.length
|
|
72
|
+
? flags.browser
|
|
73
|
+
: (proc?.browsers ?? []));
|
|
74
|
+
const modules = flags.module.length ? flags.module : (proc?.modules ?? []);
|
|
75
|
+
const retries = flags.retries ?? proc?.retries;
|
|
76
|
+
const harMode = flags.harUpdate ? 'update' : flags.harReplay ? 'replay' : proc?.harMode;
|
|
77
|
+
// Recording writes each HAR file when its context closes, so parallel workers sharing a
|
|
78
|
+
// `@har:<name>` fixture overwrite one another and the file ends up missing entries.
|
|
79
|
+
// Recording is a one-off authoring step, so serialise it instead of losing requests.
|
|
80
|
+
const workers = harMode === 'update' ? 1 : (flags.workers ?? proc?.workers);
|
|
81
|
+
if (harMode === 'update' && (flags.workers ?? proc?.workers ?? 0) > 1) {
|
|
82
|
+
warn('Recording HAR fixtures runs with a single worker so shared files keep every request.');
|
|
83
|
+
}
|
|
84
|
+
const failOnFlaky = flags.failOnFlaky ?? proc?.failOnFlaky ?? false;
|
|
85
|
+
for (const l of layers) {
|
|
86
|
+
if (!projectCfg.layers.includes(l)) {
|
|
87
|
+
throw new SdodsError('CONFIG_INVALID', `Layer "${l}" is not enabled for project ${entry.slug} (layers: ${projectCfg.layers.join(', ')}).`, { exitCode: 2 });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const b of browsers) {
|
|
91
|
+
if (!projectCfg.browsers.includes(b)) {
|
|
92
|
+
throw new SdodsError('CONFIG_INVALID', `Browser "${b}" is not enabled for project ${entry.slug} (browsers: ${projectCfg.browsers.join(', ')}).`, { exitCode: 2 });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
for (const m of modules)
|
|
96
|
+
moduleByName(projectCfg, m);
|
|
97
|
+
const runId = flags.runId ?? newRunId();
|
|
98
|
+
const cli = {
|
|
99
|
+
env: envName,
|
|
100
|
+
headed: flags.headed,
|
|
101
|
+
workers,
|
|
102
|
+
shard: flags.shard,
|
|
103
|
+
retries,
|
|
104
|
+
runId,
|
|
105
|
+
artifactsDir: flags.artifactsDir,
|
|
106
|
+
harMode: harMode,
|
|
107
|
+
offline: flags.strict && harMode === 'replay' ? true : undefined,
|
|
108
|
+
updateSnapshots: flags.updateSnapshots,
|
|
109
|
+
};
|
|
110
|
+
const cfg = ctx.registry.resolve(entry.slug, envName, cli);
|
|
111
|
+
const runDir = cfg.runtime.runDir;
|
|
112
|
+
mkdirSync(runDir, { recursive: true });
|
|
113
|
+
const manifest = {
|
|
114
|
+
runId,
|
|
115
|
+
projectSlug: entry.slug,
|
|
116
|
+
env: cfg.env.name,
|
|
117
|
+
tagsExpr: tags,
|
|
118
|
+
layers: layers.length ? layers : projectCfg.layers,
|
|
119
|
+
browsers: browsers.length ? browsers : projectCfg.browsers,
|
|
120
|
+
suiteTag: tags && /^@[a-z]+$/.test(tags) ? tags : undefined,
|
|
121
|
+
trigger: flags.trigger ?? 'cli',
|
|
122
|
+
git: await gitInfo(ctx.rootDir),
|
|
123
|
+
ci: process.env.GITHUB_ACTIONS
|
|
124
|
+
? {
|
|
125
|
+
provider: 'github',
|
|
126
|
+
runId: process.env.GITHUB_RUN_ID,
|
|
127
|
+
url: `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`,
|
|
128
|
+
}
|
|
129
|
+
: undefined,
|
|
130
|
+
startedAt: new Date().toISOString(),
|
|
131
|
+
command: process.argv.slice(2).join(' '),
|
|
132
|
+
shardIndex: cfg.runtime.shard?.current,
|
|
133
|
+
shardTotal: cfg.runtime.shard?.total,
|
|
134
|
+
process: proc?.name,
|
|
135
|
+
modules: modules.length ? modules : undefined,
|
|
136
|
+
sdodsVersion: VERSION,
|
|
137
|
+
};
|
|
138
|
+
const writeManifest = () => writeFileSync(join(runDir, runFiles.manifest), JSON.stringify(manifest, null, 2));
|
|
139
|
+
writeManifest();
|
|
140
|
+
if (flags.lint !== false) {
|
|
141
|
+
const result = await lintProject({ project: cfg.project });
|
|
142
|
+
if (result.errors.length) {
|
|
143
|
+
process.stderr.write(formatFindings(result) + '\n');
|
|
144
|
+
throw new SdodsError('LINT_FAILED', `${result.errors.length} lint error(s) in project ${entry.slug}. Fix them or pass --no-lint.`, { exitCode: 3 });
|
|
145
|
+
}
|
|
146
|
+
if (result.warnings.length && ctx.opts.verbose)
|
|
147
|
+
process.stderr.write(formatFindings(result) + '\n');
|
|
148
|
+
}
|
|
149
|
+
const selection = {
|
|
150
|
+
project: entry.slug,
|
|
151
|
+
env: cfg.env.name,
|
|
152
|
+
layers: layers.length ? layers : undefined,
|
|
153
|
+
browsers: browsers.length ? browsers : undefined,
|
|
154
|
+
tags,
|
|
155
|
+
runId,
|
|
156
|
+
allure: flags.allure,
|
|
157
|
+
reporters: flags.reporter.length ? flags.reporter : undefined,
|
|
158
|
+
reporterMode: flags.reporterMode ??
|
|
159
|
+
(ctx.opts.quiet ? 'quiet' : 'default'),
|
|
160
|
+
};
|
|
161
|
+
const childEnv = {
|
|
162
|
+
...process.env,
|
|
163
|
+
SDODS_ROOT: ctx.rootDir,
|
|
164
|
+
SDODS_PROJECT: entry.slug,
|
|
165
|
+
SDODS_ENV: cfg.env.name,
|
|
166
|
+
SDODS_TAGS: tags ?? '',
|
|
167
|
+
SDODS_LAYERS: selection.layers?.join(',') ?? '',
|
|
168
|
+
SDODS_BROWSERS: selection.browsers?.join(',') ?? '',
|
|
169
|
+
SDODS_RUN_ID: runId,
|
|
170
|
+
SDODS_ALLURE: flags.allure ? '1' : '',
|
|
171
|
+
SDODS_REPORTERS: selection.reporters?.join(',') ?? '',
|
|
172
|
+
SDODS_REPORTER_MODE: selection.reporterMode ?? 'default',
|
|
173
|
+
SDODS_PROCESS: proc?.name ?? '',
|
|
174
|
+
SDODS_MODULES: modules.join(','),
|
|
175
|
+
[CLI_OVERRIDES_ENV]: serializeCliOverrides(cli),
|
|
176
|
+
FORCE_COLOR: ctx.opts.color === false ? '0' : (process.env.FORCE_COLOR ?? '1'),
|
|
177
|
+
};
|
|
178
|
+
if (flags.updateSnapshots)
|
|
179
|
+
childEnv.SDODS_UPDATE_SNAPSHOTS = '1';
|
|
180
|
+
if (harMode)
|
|
181
|
+
childEnv.SDODS_HAR_MODE = harMode;
|
|
182
|
+
if (flags.strict && harMode === 'replay')
|
|
183
|
+
childEnv.SDODS_OFFLINE = '1';
|
|
184
|
+
const configPath = join(ctx.rootDir, 'sdods.runner.config.ts');
|
|
185
|
+
if (!existsSync(configPath)) {
|
|
186
|
+
throw new SdodsError('CONFIG_NOT_FOUND', `No runner config at ${configPath}.`, {
|
|
187
|
+
hint: 'Run `sdods init` or copy the template from the SDODS repo.',
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
const runnerProjects = listGeneratedProjects(ctx.registry, selection);
|
|
191
|
+
if (!runnerProjects.length) {
|
|
192
|
+
throw new SdodsError('CONFIG_INVALID', 'The selection produced no run targets.', {
|
|
193
|
+
hint: 'Check --layer / --browser against the project yaml.',
|
|
194
|
+
exitCode: 2,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
// Recorded specs are plain runner tests: no Gherkin to generate.
|
|
198
|
+
const recordedOnly = runnerProjects.every((p) => p.layer === 'recorded');
|
|
199
|
+
if (!recordedOnly) {
|
|
200
|
+
const gen = await execa('npx', ['bddgen', '-c', configPath], {
|
|
201
|
+
cwd: ctx.rootDir,
|
|
202
|
+
env: childEnv,
|
|
203
|
+
stdio: ctx.opts.quiet ? 'pipe' : 'inherit',
|
|
204
|
+
reject: false,
|
|
205
|
+
});
|
|
206
|
+
if (gen.exitCode !== 0) {
|
|
207
|
+
if (ctx.opts.quiet && gen.stderr)
|
|
208
|
+
process.stderr.write(gen.stderr + '\n');
|
|
209
|
+
throw new SdodsError('RUN_FAILED', 'bddgen failed to generate specs.', {
|
|
210
|
+
hint: 'Run `sdods lint -p <slug> --undefined-steps` to find undefined steps.',
|
|
211
|
+
exitCode: 2,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const args = ['playwright', 'test', '-c', configPath, '--pass-with-no-tests'];
|
|
216
|
+
for (const p of runnerProjects)
|
|
217
|
+
args.push('--project', p.name);
|
|
218
|
+
if (flags.headed)
|
|
219
|
+
args.push('--headed');
|
|
220
|
+
if (workers)
|
|
221
|
+
args.push('--workers', String(workers));
|
|
222
|
+
if (flags.shard)
|
|
223
|
+
args.push('--shard', flags.shard);
|
|
224
|
+
if (retries !== undefined)
|
|
225
|
+
args.push('--retries', String(retries));
|
|
226
|
+
if (flags.grep)
|
|
227
|
+
args.push('--grep', flags.grep);
|
|
228
|
+
if (flags.scenario)
|
|
229
|
+
args.push('--grep', escapeRe(flags.scenario));
|
|
230
|
+
if (flags.repeatEach)
|
|
231
|
+
args.push('--repeat-each', String(flags.repeatEach));
|
|
232
|
+
if (flags.maxFailures)
|
|
233
|
+
args.push('--max-failures', String(flags.maxFailures));
|
|
234
|
+
if (flags.timeout)
|
|
235
|
+
args.push('--timeout', String(flags.timeout));
|
|
236
|
+
if (flags.updateSnapshots)
|
|
237
|
+
args.push('--update-snapshots');
|
|
238
|
+
if (failOnFlaky)
|
|
239
|
+
args.push('--fail-on-flaky-tests');
|
|
240
|
+
if (flags.ui)
|
|
241
|
+
args.push('--ui');
|
|
242
|
+
if (flags.debug)
|
|
243
|
+
args.push('--debug');
|
|
244
|
+
if (flags.list)
|
|
245
|
+
args.push('--list');
|
|
246
|
+
// module / feature restriction → generated spec path filters (features/<dir>/x.feature → .sdods/generated/<slug>/<layer>/<dir>/x.feature.spec.js)
|
|
247
|
+
const filters = [];
|
|
248
|
+
for (const m of modules) {
|
|
249
|
+
const rel = relative(join(cfg.project.root, 'features'), moduleDir(cfg.project.root, moduleByName(projectCfg, m))).replace(/\\/g, '/');
|
|
250
|
+
filters.push(`\\.sdods/generated/${escapeRe(runId)}/${escapeRe(entry.slug)}/[^/]+/${escapeRe(rel)}/`);
|
|
251
|
+
}
|
|
252
|
+
if (flags.feature)
|
|
253
|
+
filters.push(escapeRe(flags.feature.replace(/^features\//, '').replace(/\.feature$/, '')) +
|
|
254
|
+
'\\.feature\\.spec');
|
|
255
|
+
// positional filters must precede `--project` (variadic in the runner CLI)
|
|
256
|
+
args.splice(4, 0, ...filters);
|
|
257
|
+
if (flags.list) {
|
|
258
|
+
out(pc.bold('Run targets:'));
|
|
259
|
+
for (const p of runnerProjects)
|
|
260
|
+
out(` ${p.name}`);
|
|
261
|
+
const listed = await execa('npx', args, {
|
|
262
|
+
cwd: ctx.rootDir,
|
|
263
|
+
env: childEnv,
|
|
264
|
+
stdio: 'inherit',
|
|
265
|
+
reject: false,
|
|
266
|
+
});
|
|
267
|
+
rmSync(join(ctx.rootDir, '.sdods', 'generated', runId), { recursive: true, force: true });
|
|
268
|
+
rmSync(runDir, { recursive: true, force: true });
|
|
269
|
+
return listed.exitCode ?? 0;
|
|
270
|
+
}
|
|
271
|
+
out(pc.dim(`run ${runId} · project ${entry.slug} · env ${cfg.env.name}${tags ? ` · tags ${tags}` : ''}${proc ? ` · process ${proc.name}` : ''}${modules.length ? ` · modules ${modules.join(',')}` : ''}`));
|
|
272
|
+
out(pc.dim(`projects: ${runnerProjects.map((p) => p.name).join(', ')}`));
|
|
273
|
+
const started = Date.now();
|
|
274
|
+
const child = execa('npx', args, {
|
|
275
|
+
cwd: ctx.rootDir,
|
|
276
|
+
env: childEnv,
|
|
277
|
+
stdio: 'inherit',
|
|
278
|
+
reject: false,
|
|
279
|
+
});
|
|
280
|
+
const onSignal = () => child.kill('SIGTERM');
|
|
281
|
+
process.once('SIGINT', onSignal);
|
|
282
|
+
process.once('SIGTERM', onSignal);
|
|
283
|
+
const result = await child;
|
|
284
|
+
process.off('SIGINT', onSignal);
|
|
285
|
+
process.off('SIGTERM', onSignal);
|
|
286
|
+
const exitCode = result.signal === 'SIGINT' || result.signal === 'SIGTERM' ? 130 : (result.exitCode ?? 1);
|
|
287
|
+
rmSync(join(ctx.rootDir, '.sdods', 'generated', runId), { recursive: true, force: true });
|
|
288
|
+
manifest.finishedAt = new Date().toISOString();
|
|
289
|
+
manifest.exitCode = exitCode;
|
|
290
|
+
writeManifest();
|
|
291
|
+
const summary = readSummary(runDir, runId, exitCode, Date.now() - started);
|
|
292
|
+
if (summary)
|
|
293
|
+
writeFileSync(join(runDir, runFiles.summary), JSON.stringify(summary, null, 2));
|
|
294
|
+
const dbConfigured = process.env.DB_DRIVER === 'postgres'
|
|
295
|
+
? Boolean(process.env.DATABASE_URL)
|
|
296
|
+
: existsSync(resolvePath(ctx.rootDir, process.env.SQLITE_PATH ?? '.sdods/sdods.db'));
|
|
297
|
+
if (flags.ingest === true || (flags.ingest !== false && dbConfigured)) {
|
|
298
|
+
try {
|
|
299
|
+
const dbModule = '@sdods/db';
|
|
300
|
+
const mod = (await import(dbModule));
|
|
301
|
+
const adb = await mod.openDb();
|
|
302
|
+
try {
|
|
303
|
+
await mod.ingestRun(adb, { artifactsRoot: cfg.runtime.artifactsDir, runId });
|
|
304
|
+
manifest.ingestedAt = new Date().toISOString();
|
|
305
|
+
writeManifest();
|
|
306
|
+
out(pc.dim(`ingested run ${runId} into the ${process.env.DB_DRIVER ?? 'sqlite'} database`));
|
|
307
|
+
}
|
|
308
|
+
finally {
|
|
309
|
+
await adb.close();
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
catch (e) {
|
|
313
|
+
warn(`ingest skipped: ${e.message}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (summary && summary.totals.total === 0)
|
|
317
|
+
warn(`No scenarios matched the selection${tags ? ` (tags: ${tags})` : ''}.`);
|
|
318
|
+
if (ctx.opts.json) {
|
|
319
|
+
json({ runId, runDir, exitCode, summary, manifest });
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
const t = summary?.totals;
|
|
323
|
+
out('');
|
|
324
|
+
out(`${exitCode === 0 ? pc.green('✔ passed') : exitCode === 130 ? pc.yellow('■ cancelled') : pc.red('✖ failed')} ${t ? `${t.passed} passed, ${t.failed} failed, ${t.skipped} skipped, ${t.flaky} flaky` : ''} ${pc.dim(`(${Math.round((Date.now() - started) / 1000)}s)`)}`);
|
|
325
|
+
out(pc.dim(`artifacts: ${runDir}`));
|
|
326
|
+
out(pc.dim(`html report: ${join(runDir, runFiles.htmlReport, 'index.html')}`));
|
|
327
|
+
out(pc.dim(`dashboard: ${join(runDir, runFiles.dashboard, 'index.html')}`));
|
|
328
|
+
}
|
|
329
|
+
return exitCode;
|
|
330
|
+
}
|
|
331
|
+
function escapeRe(s) {
|
|
332
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
333
|
+
}
|
|
334
|
+
async function gitInfo(cwd) {
|
|
335
|
+
try {
|
|
336
|
+
const sha = (await execa('git', ['rev-parse', 'HEAD'], { cwd })).stdout.trim();
|
|
337
|
+
const branch = (await execa('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd })).stdout.trim();
|
|
338
|
+
const dirty = (await execa('git', ['status', '--porcelain'], { cwd })).stdout.trim().length > 0;
|
|
339
|
+
return { sha, branch, dirty };
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
return undefined;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
function readSummary(runDir, runId, exitCode, durationMs) {
|
|
346
|
+
const metrics = join(runDir, runFiles.dashboard, 'metrics.json');
|
|
347
|
+
if (!existsSync(metrics))
|
|
348
|
+
return undefined;
|
|
349
|
+
try {
|
|
350
|
+
const m = JSON.parse(readFileSync(metrics, 'utf8'));
|
|
351
|
+
return {
|
|
352
|
+
runId,
|
|
353
|
+
status: exitCode === 0 ? 'passed' : exitCode === 130 ? 'cancelled' : 'failed',
|
|
354
|
+
totals: { ...m.summary, durationMs },
|
|
355
|
+
byProject: m.byProject,
|
|
356
|
+
failed: m.failed ?? [],
|
|
357
|
+
flaky: m.flaky ?? [],
|
|
358
|
+
reportPaths: {
|
|
359
|
+
html: join(runDir, runFiles.htmlReport, 'index.html'),
|
|
360
|
+
dashboard: join(runDir, runFiles.dashboard, 'index.html'),
|
|
361
|
+
messages: join(runDir, runFiles.messages),
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
//# sourceMappingURL=run.js.map
|