@danwo/cli 0.0.0-stage → 0.1.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 +201 -0
- package/NOTICE +12 -0
- package/README.md +29 -2
- package/bin/danwo.mjs +8 -0
- package/package.json +67 -4
- package/src/bin.ts +798 -0
- package/src/commands.ts +39 -0
- package/src/doctor.ts +254 -0
package/src/bin.ts
ADDED
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* `danwo test` and `danwo matrix`. Runs under Node 22 or 24 with native type stripping; the runner
|
|
4
|
+
* forks its workers with its own resolve hook.
|
|
5
|
+
*/
|
|
6
|
+
import { resolve as resolvePath } from "node:path"
|
|
7
|
+
|
|
8
|
+
import "@danwo/runner/register"
|
|
9
|
+
import { github } from "@danwo/github"
|
|
10
|
+
import type { InitFlags } from "@danwo/init"
|
|
11
|
+
import { AGENTS, DANWO_VERSION, ENGINES, MODEL_SOURCES, PROVIDER_IDS } from "@danwo/init/catalog"
|
|
12
|
+
import {
|
|
13
|
+
type CacheAction,
|
|
14
|
+
exitCodeForError,
|
|
15
|
+
explainImpact,
|
|
16
|
+
junit,
|
|
17
|
+
lintRun,
|
|
18
|
+
mergeReports,
|
|
19
|
+
parseDuration,
|
|
20
|
+
type RecordMode,
|
|
21
|
+
type Reporter,
|
|
22
|
+
runCache,
|
|
23
|
+
runMatrix,
|
|
24
|
+
runTests,
|
|
25
|
+
showRun,
|
|
26
|
+
type VideoMode,
|
|
27
|
+
} from "@danwo/runner/runner"
|
|
28
|
+
import { Argument, Command, InvalidArgumentError, Option } from "commander"
|
|
29
|
+
|
|
30
|
+
import { cliCommands } from "./commands.ts"
|
|
31
|
+
|
|
32
|
+
/** What `--reporter` and `--no-pr-comment` set. */
|
|
33
|
+
interface ReporterFlags {
|
|
34
|
+
readonly reporter?: string[] | undefined
|
|
35
|
+
/** `false` with `--no-pr-comment`. */
|
|
36
|
+
readonly prComment?: boolean | undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Reporters by the names `--reporter` takes; the console's list output always runs. */
|
|
40
|
+
const REPORTERS: Readonly<Record<string, (flags: ReporterFlags) => Reporter>> = {
|
|
41
|
+
github: (flags) => github(flags.prComment === false ? { prComment: false } : {}),
|
|
42
|
+
junit: () => junit(),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The reporters named by `--reporter`, comma-separated or repeated. */
|
|
46
|
+
function reportersOf({ reporter: names, ...flags }: ReporterFlags): Reporter[] {
|
|
47
|
+
return (names ?? [])
|
|
48
|
+
.flatMap((name) => name.split(","))
|
|
49
|
+
.map((name) => name.trim())
|
|
50
|
+
.filter((name) => name !== "" && name !== "list")
|
|
51
|
+
.map((name) => {
|
|
52
|
+
const make = REPORTERS[name]
|
|
53
|
+
|
|
54
|
+
if (make === undefined) {
|
|
55
|
+
throw new InvalidArgumentError(`unknown reporter "${name}" (known: list, github, junit)`)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return make(flags)
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `--max-duration`'s value in milliseconds. */
|
|
63
|
+
function duration(value: string): number {
|
|
64
|
+
const ms = parseDuration(value)
|
|
65
|
+
|
|
66
|
+
if (ms === undefined) throw new InvalidArgumentError("expected a duration such as 20m, 90s or 1h")
|
|
67
|
+
|
|
68
|
+
return ms
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function integerAtLeast(min: number): (value: string) => number {
|
|
72
|
+
return (value) => {
|
|
73
|
+
const n = Number(value)
|
|
74
|
+
|
|
75
|
+
if (!Number.isInteger(n) || n < min) {
|
|
76
|
+
throw new InvalidArgumentError(`expected an integer >= ${min}`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return n
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const recordOption = () =>
|
|
84
|
+
new Option(
|
|
85
|
+
"--record <mode>",
|
|
86
|
+
"flight recorder: keep what runs did (default: config, else always)",
|
|
87
|
+
).choices(["on-failure", "always", "off"])
|
|
88
|
+
|
|
89
|
+
const videoOption = () =>
|
|
90
|
+
new Option(
|
|
91
|
+
"--video <mode>",
|
|
92
|
+
"video in kept records: retain-on-failure keeps failed runs' (default: config, else on)",
|
|
93
|
+
).choices(["retain-on-failure", "on", "off"])
|
|
94
|
+
|
|
95
|
+
const keepRunsOption = () =>
|
|
96
|
+
new Option(
|
|
97
|
+
"--keep-runs <n>",
|
|
98
|
+
"how many of the newest runs keep their recordings (default: config, else 50)",
|
|
99
|
+
).argParser(integerAtLeast(1))
|
|
100
|
+
|
|
101
|
+
const updateOption = () =>
|
|
102
|
+
new Option("-u, --update-snapshots", "rewrite every toHaveScreenshot baseline")
|
|
103
|
+
|
|
104
|
+
/** `--lint` and `--no-lint`; neither given follows the config (on unless it says `lint: false`). */
|
|
105
|
+
function withLintOptions(command: Command): Command {
|
|
106
|
+
return command
|
|
107
|
+
.option("--lint", "run the integration lint over provider calls and webhooks (default: on)")
|
|
108
|
+
.option("--no-lint", "skip the integration lint")
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const reporterOption = () =>
|
|
112
|
+
new Option(
|
|
113
|
+
"--reporter <names...>",
|
|
114
|
+
"reporters besides the console's list: github (a check run, a pull request comment and the job summary in GitHub Actions), junit (.danwo/junit.xml)",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
/** `--reporter`, and `--no-pr-comment` for the github reporter. */
|
|
118
|
+
function withReporterOptions(command: Command): Command {
|
|
119
|
+
return command
|
|
120
|
+
.addOption(reporterOption())
|
|
121
|
+
.option("--no-pr-comment", "github reporter: no pull request comment, only the check run")
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const program = new Command()
|
|
125
|
+
.name("danwo")
|
|
126
|
+
.description("Drive your app like a user, judge the layers below, and run the fault matrix.")
|
|
127
|
+
.version(DANWO_VERSION)
|
|
128
|
+
|
|
129
|
+
withReporterOptions(withLintOptions(program.command("test")))
|
|
130
|
+
.description("run test files in worker processes")
|
|
131
|
+
.argument("[patterns...]", "file paths or substrings narrowing the config's testMatch")
|
|
132
|
+
.option("-w, --workers <n>", "worker processes", integerAtLeast(1))
|
|
133
|
+
.option(
|
|
134
|
+
"--split-files",
|
|
135
|
+
"hand workers single tests of the files whose tests share nothing (no beforeAll or afterAll)",
|
|
136
|
+
)
|
|
137
|
+
.option("-r, --retries <n>", "retries per test", integerAtLeast(0))
|
|
138
|
+
.option("-g, --grep <regexp>", "only tests whose full title matches")
|
|
139
|
+
.option("--last-failed", "only the tests that failed in the latest run; merges into it")
|
|
140
|
+
.option("--changed", "only the tests the changed files can affect (see danwo impact)")
|
|
141
|
+
.option("--since <ref>", "what --changed compares with (default: HEAD, uncommitted changes)")
|
|
142
|
+
.option("--shard <i/n>", "this machine's share of the test files, e.g. 2/4")
|
|
143
|
+
.option(
|
|
144
|
+
"--max-failures <n>",
|
|
145
|
+
"start no new test file once this many tests failed",
|
|
146
|
+
integerAtLeast(1),
|
|
147
|
+
)
|
|
148
|
+
.option(
|
|
149
|
+
"--max-duration <time>",
|
|
150
|
+
"start no new test file once the run has taken this long, e.g. 20m (files left out fail the run)",
|
|
151
|
+
duration,
|
|
152
|
+
)
|
|
153
|
+
.option("--ci", "CI defaults even without the CI variable: 1 retry, read-only cache, no .only")
|
|
154
|
+
.option(
|
|
155
|
+
"--replay-only",
|
|
156
|
+
"replay recorded agent steps and call no model: a step with no recording that replays fails",
|
|
157
|
+
)
|
|
158
|
+
.option(
|
|
159
|
+
"--browser <names>",
|
|
160
|
+
"the browsers to run in: chromium, firefox or webkit, or several as chromium,firefox to run every test in each (default: the config's browser.name, else chromium)",
|
|
161
|
+
)
|
|
162
|
+
.option("--list", "print the picked tests as JSON (id, file) instead of running them")
|
|
163
|
+
.option("-c, --config <path>", "config file (default: nearest danwo.config.ts)")
|
|
164
|
+
.addOption(recordOption())
|
|
165
|
+
.addOption(videoOption())
|
|
166
|
+
.addOption(keepRunsOption())
|
|
167
|
+
.addOption(updateOption())
|
|
168
|
+
.action(
|
|
169
|
+
async (
|
|
170
|
+
patterns: string[],
|
|
171
|
+
{
|
|
172
|
+
updateSnapshots,
|
|
173
|
+
reporter,
|
|
174
|
+
prComment,
|
|
175
|
+
maxDuration,
|
|
176
|
+
...options
|
|
177
|
+
}: {
|
|
178
|
+
workers?: number
|
|
179
|
+
splitFiles?: boolean
|
|
180
|
+
retries?: number
|
|
181
|
+
grep?: string
|
|
182
|
+
lastFailed?: boolean
|
|
183
|
+
changed?: boolean
|
|
184
|
+
since?: string
|
|
185
|
+
shard?: string
|
|
186
|
+
maxFailures?: number
|
|
187
|
+
maxDuration?: number
|
|
188
|
+
ci?: boolean
|
|
189
|
+
replayOnly?: boolean
|
|
190
|
+
browser?: string
|
|
191
|
+
list?: boolean
|
|
192
|
+
config?: string
|
|
193
|
+
record?: RecordMode
|
|
194
|
+
video?: VideoMode
|
|
195
|
+
keepRuns?: number
|
|
196
|
+
updateSnapshots?: boolean
|
|
197
|
+
reporter?: string[]
|
|
198
|
+
prComment?: boolean
|
|
199
|
+
lint?: boolean
|
|
200
|
+
},
|
|
201
|
+
) => {
|
|
202
|
+
process.exitCode = await runTests({
|
|
203
|
+
patterns,
|
|
204
|
+
...options,
|
|
205
|
+
...(maxDuration !== undefined && { maxDurationMs: maxDuration }),
|
|
206
|
+
reporters: reportersOf({ reporter, prComment }),
|
|
207
|
+
...(updateSnapshots && { updateScreenshots: true }),
|
|
208
|
+
})
|
|
209
|
+
},
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
withReporterOptions(withLintOptions(program.command("matrix")))
|
|
213
|
+
.description("record a clean run of one test, then replay it once per planned fault")
|
|
214
|
+
.argument("<file>", "test file")
|
|
215
|
+
.option("-g, --grep <regexp>", "selects the test when the file has several")
|
|
216
|
+
.option("-f, --faults <regexp>", "only the planned faults whose labels match")
|
|
217
|
+
.option(
|
|
218
|
+
"-w, --workers <n>",
|
|
219
|
+
"worker processes (default: one per core, at most 8)",
|
|
220
|
+
integerAtLeast(1),
|
|
221
|
+
)
|
|
222
|
+
.option("--max-failures <n>", "start no new run once this many failed", integerAtLeast(1))
|
|
223
|
+
.option("--shuffle <seed>", "run the faulted runs in a shuffled order", integerAtLeast(0))
|
|
224
|
+
.option(
|
|
225
|
+
"--plan",
|
|
226
|
+
"run the clean run, print the planned faults and how long they would take, and stop",
|
|
227
|
+
)
|
|
228
|
+
.option(
|
|
229
|
+
"--budget <n>",
|
|
230
|
+
"at most this many faulted runs (default: config, else 40)",
|
|
231
|
+
integerAtLeast(1),
|
|
232
|
+
)
|
|
233
|
+
.option("--all", "every fault on the test's body, background work included, with no budget")
|
|
234
|
+
.option("--thorough", "every fault kind on each call, not only the most telling ones")
|
|
235
|
+
.option(
|
|
236
|
+
"--every-occurrence",
|
|
237
|
+
"faults on every repeat of a route or statement, not only its first",
|
|
238
|
+
)
|
|
239
|
+
.option(
|
|
240
|
+
"--include-setup",
|
|
241
|
+
"fault the test's setup too: its saved session signing in, fixtures and beforeEach",
|
|
242
|
+
)
|
|
243
|
+
.option(
|
|
244
|
+
"--browser <name>",
|
|
245
|
+
"the one browser every run is in: chromium, firefox or webkit (default: the config's first browser.name, else chromium)",
|
|
246
|
+
)
|
|
247
|
+
.option("-c, --config <path>", "config file (default: nearest danwo.config.ts)")
|
|
248
|
+
.addOption(recordOption())
|
|
249
|
+
.addOption(videoOption())
|
|
250
|
+
.addOption(keepRunsOption())
|
|
251
|
+
.addOption(updateOption())
|
|
252
|
+
.action(
|
|
253
|
+
async (
|
|
254
|
+
file: string,
|
|
255
|
+
{
|
|
256
|
+
updateSnapshots,
|
|
257
|
+
reporter,
|
|
258
|
+
prComment,
|
|
259
|
+
...options
|
|
260
|
+
}: {
|
|
261
|
+
grep?: string
|
|
262
|
+
faults?: string
|
|
263
|
+
workers?: number
|
|
264
|
+
maxFailures?: number
|
|
265
|
+
shuffle?: number
|
|
266
|
+
plan?: boolean
|
|
267
|
+
budget?: number
|
|
268
|
+
all?: boolean
|
|
269
|
+
thorough?: boolean
|
|
270
|
+
everyOccurrence?: boolean
|
|
271
|
+
includeSetup?: boolean
|
|
272
|
+
browser?: string
|
|
273
|
+
config?: string
|
|
274
|
+
record?: RecordMode
|
|
275
|
+
video?: VideoMode
|
|
276
|
+
keepRuns?: number
|
|
277
|
+
updateSnapshots?: boolean
|
|
278
|
+
reporter?: string[]
|
|
279
|
+
prComment?: boolean
|
|
280
|
+
lint?: boolean
|
|
281
|
+
},
|
|
282
|
+
) => {
|
|
283
|
+
const update = updateSnapshots ? { updateScreenshots: true } : {}
|
|
284
|
+
const reporters = reportersOf({ reporter, prComment })
|
|
285
|
+
|
|
286
|
+
process.exitCode = (await runMatrix({ file, ...options, reporters, ...update })).code
|
|
287
|
+
},
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
withReporterOptions(program.command("merge-reports"))
|
|
291
|
+
.description("join the results of a split run (danwo test --shard i/n) into one report")
|
|
292
|
+
.argument("[files...]", "results files (default: .danwo/results/shard-*.json)")
|
|
293
|
+
.option("-c, --config <path>", "config file (default: nearest danwo.config.ts)")
|
|
294
|
+
.action(async (files: string[], options: { config?: string } & ReporterFlags) => {
|
|
295
|
+
process.exitCode = await mergeReports({
|
|
296
|
+
files,
|
|
297
|
+
...(options.config !== undefined && { config: options.config }),
|
|
298
|
+
reporters: reportersOf(options),
|
|
299
|
+
})
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
program
|
|
303
|
+
.command("impact")
|
|
304
|
+
.description("which tests a change to these files selects for danwo test --changed, and why")
|
|
305
|
+
.argument("<files...>", "changed files")
|
|
306
|
+
.option("-c, --config <path>", "config file (default: nearest danwo.config.ts)")
|
|
307
|
+
.action(async (files: string[], options: { config?: string }) => {
|
|
308
|
+
process.exitCode = await explainImpact({
|
|
309
|
+
files,
|
|
310
|
+
...(options.config !== undefined && { config: options.config }),
|
|
311
|
+
})
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
program
|
|
315
|
+
.command("mcp")
|
|
316
|
+
.description("serve this project to coding agents over MCP (stdio)")
|
|
317
|
+
.option("-c, --config <path>", "config file (default: nearest danwo.config.ts)")
|
|
318
|
+
.option("--max-sessions <n>", "live sessions open at once", integerAtLeast(1), 4)
|
|
319
|
+
.action(async (options: { config?: string; maxSessions: number }) => {
|
|
320
|
+
const { serveStdio } = await import("@danwo/mcp")
|
|
321
|
+
|
|
322
|
+
await serveStdio({
|
|
323
|
+
cwd: process.cwd(),
|
|
324
|
+
config: options.config,
|
|
325
|
+
sessions: { max: options.maxSessions },
|
|
326
|
+
version: DANWO_VERSION,
|
|
327
|
+
commands: cliCommands(program),
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
program
|
|
332
|
+
.command("init")
|
|
333
|
+
.description(
|
|
334
|
+
"set danwo up in this project: the engine, the model for agent steps, the providers to emulate and the coding agents to teach; writes danwo.config.ts, an example test and a test:danwo script, and installs what they import. Asks in a terminal; with --yes or no terminal it takes the flags and what it detects",
|
|
335
|
+
)
|
|
336
|
+
.option("-y, --yes", "ask nothing: the flags, and the detected defaults for the rest")
|
|
337
|
+
.addOption(new Option("--engine <engine>", "what tests drive").choices(ENGINES))
|
|
338
|
+
.addOption(
|
|
339
|
+
new Option("--model-source <source>", "where agent steps get their model").choices(
|
|
340
|
+
MODEL_SOURCES,
|
|
341
|
+
),
|
|
342
|
+
)
|
|
343
|
+
.option("--model <id>", "the model id, e.g. anthropic/claude-sonnet-5 on the AI Gateway")
|
|
344
|
+
.option(
|
|
345
|
+
"--base-url <url>",
|
|
346
|
+
"an OpenAI-compatible server's URL (--model-source openai-compatible)",
|
|
347
|
+
)
|
|
348
|
+
.option("--key-env <name>", "the variable holding that server's key, when it takes one")
|
|
349
|
+
.option(
|
|
350
|
+
"--providers <list>",
|
|
351
|
+
`providers to emulate, comma-separated, or none (${PROVIDER_IDS.join(", ")})`,
|
|
352
|
+
)
|
|
353
|
+
.option(
|
|
354
|
+
"--skills <list>",
|
|
355
|
+
`coding agents to install danwo's skill for, comma-separated, or none (${AGENTS.join(", ")})`,
|
|
356
|
+
)
|
|
357
|
+
.option("--no-mcp", "do not register danwo's MCP server with those agents")
|
|
358
|
+
.option("--dev-command <command>", "the command that starts the app, e.g. 'pnpm dev'")
|
|
359
|
+
.option("--app-url <url>", "where the app answers once started")
|
|
360
|
+
.option("--skip-install", "write the files but install no packages")
|
|
361
|
+
.option("--force", "replace a danwo.config.ts that differs from the one init writes")
|
|
362
|
+
.option(
|
|
363
|
+
"--browser <names>",
|
|
364
|
+
"the browsers web tests run in: chromium, firefox, webkit, or several as chromium,firefox (default: chromium)",
|
|
365
|
+
)
|
|
366
|
+
.action(async (options: InitFlags) => {
|
|
367
|
+
const { InitCancelled, runInit, terminalPrompter } = await import("@danwo/init")
|
|
368
|
+
const interactive = options.yes !== true && process.stdin.isTTY && process.stdout.isTTY
|
|
369
|
+
|
|
370
|
+
try {
|
|
371
|
+
const outcome = await runInit({
|
|
372
|
+
cwd: process.cwd(),
|
|
373
|
+
flags: options,
|
|
374
|
+
...(interactive && { prompter: terminalPrompter }),
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
if (!interactive) console.log(outcome.summary)
|
|
378
|
+
} catch (error) {
|
|
379
|
+
if (!(error instanceof InitCancelled)) throw error
|
|
380
|
+
process.exitCode = 130
|
|
381
|
+
}
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
program
|
|
385
|
+
.command("doctor")
|
|
386
|
+
.description(
|
|
387
|
+
"check what a first run needs: Node, danwo's Chromium and the config's other browsers, the config and its keys, the model's key, Playwright Test's version and the agents' skill",
|
|
388
|
+
)
|
|
389
|
+
.option("--json", "the checks as JSON: { ok, checks: [{ name, status, detail }] }")
|
|
390
|
+
.option("-c, --config <path>", "config file (default: nearest danwo.config.ts)")
|
|
391
|
+
.action(async (options: { json?: boolean; config?: string }) => {
|
|
392
|
+
const { doctorLines, projectDoctor } = await import("./doctor.ts")
|
|
393
|
+
const { browserInstalled } = await import("@danwo/runner")
|
|
394
|
+
const checks = await projectDoctor(
|
|
395
|
+
{ cwd: process.cwd(), config: options.config },
|
|
396
|
+
{ nodeVersion: process.version, env: process.env, browser: browserInstalled },
|
|
397
|
+
)
|
|
398
|
+
const ok = checks.every((check) => check.status !== "fail")
|
|
399
|
+
|
|
400
|
+
console.log(options.json ? JSON.stringify({ ok, checks }) : doctorLines(checks).join("\n"))
|
|
401
|
+
process.exitCode = ok ? 0 : 1
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
program
|
|
405
|
+
.command("install-browser")
|
|
406
|
+
.description(
|
|
407
|
+
"install the browser builds danwo drives (Chromium, or the ones --browser names), with danwo's own Playwright, once per machine; other Playwright installs' browsers are kept",
|
|
408
|
+
)
|
|
409
|
+
.option(
|
|
410
|
+
"--browser <names>",
|
|
411
|
+
"the browsers to install: chromium, firefox, webkit, or several as firefox,webkit (default: chromium)",
|
|
412
|
+
)
|
|
413
|
+
.option("--with-deps", "also install the system libraries the browsers need (Linux)")
|
|
414
|
+
.option(
|
|
415
|
+
"--only-shell",
|
|
416
|
+
"only the headless shell test runs use (smaller, for CI); not for danwo record",
|
|
417
|
+
)
|
|
418
|
+
.option("--dry-run", "say what would be installed, and install nothing")
|
|
419
|
+
.action(
|
|
420
|
+
async ({
|
|
421
|
+
browser,
|
|
422
|
+
...options
|
|
423
|
+
}: {
|
|
424
|
+
browser?: string
|
|
425
|
+
withDeps?: boolean
|
|
426
|
+
onlyShell?: boolean
|
|
427
|
+
dryRun?: boolean
|
|
428
|
+
}) => {
|
|
429
|
+
const { browserNames, installBrowser } = await import("@danwo/runner")
|
|
430
|
+
|
|
431
|
+
process.exitCode = await installBrowser({
|
|
432
|
+
...options,
|
|
433
|
+
...(browser !== undefined && { browsers: browserNames(browser, "--browser") }),
|
|
434
|
+
})
|
|
435
|
+
},
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
program
|
|
439
|
+
.command("record")
|
|
440
|
+
.description(
|
|
441
|
+
"record a test by using the app in a browser: locators for each action, what it caused behind the app, and a drafted invariant",
|
|
442
|
+
)
|
|
443
|
+
.argument("[path]", "the app page to start on", "/")
|
|
444
|
+
.option(
|
|
445
|
+
"-o, --out <file>",
|
|
446
|
+
"the new test file, relative to the project (default: <testDir>/recorded.danwo.ts)",
|
|
447
|
+
)
|
|
448
|
+
.option("-n, --name <title>", "the test's title", "recorded journey")
|
|
449
|
+
.option("-c, --config <path>", "config file (default: nearest danwo.config.ts)")
|
|
450
|
+
.action(async (path: string, options: { out?: string; name: string; config?: string }) => {
|
|
451
|
+
const { recordedReport, startRecording } = await import("@danwo/recorder")
|
|
452
|
+
const recording = await startRecording({ cwd: process.cwd(), config: options.config, path })
|
|
453
|
+
const interrupted = new Promise<void>((resolve) => process.once("SIGINT", () => resolve()))
|
|
454
|
+
|
|
455
|
+
console.log(
|
|
456
|
+
"recording: use the app in the browser, then press Stop on the danwo toolbar (or Ctrl-C)",
|
|
457
|
+
)
|
|
458
|
+
await Promise.race([recording.stopped, interrupted])
|
|
459
|
+
console.log(recordedReport(await recording.finish({ out: options.out, name: options.name })))
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
program
|
|
463
|
+
.command("show")
|
|
464
|
+
.description("print the path of a recorded run's report (the newest by default)")
|
|
465
|
+
.argument("[run-id]", "a run under .danwo/runs")
|
|
466
|
+
.option("-o, --open", "open the report in the browser")
|
|
467
|
+
.addOption(
|
|
468
|
+
new Option(
|
|
469
|
+
"--format <format>",
|
|
470
|
+
"html: the report's path; md: a failure page for each failed run",
|
|
471
|
+
)
|
|
472
|
+
.choices(["html", "md"])
|
|
473
|
+
.default("html"),
|
|
474
|
+
)
|
|
475
|
+
.option("-c, --config <path>", "config file (default: nearest danwo.config.ts)")
|
|
476
|
+
.action(
|
|
477
|
+
async (
|
|
478
|
+
runId: string | undefined,
|
|
479
|
+
options: { open?: boolean; config?: string; format?: "html" | "md" },
|
|
480
|
+
) => {
|
|
481
|
+
process.exitCode = await showRun({ ...(runId !== undefined && { runId }), ...options })
|
|
482
|
+
},
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
program
|
|
486
|
+
.command("lint")
|
|
487
|
+
.description(
|
|
488
|
+
"run the integration lint again over a recorded run (the newest by default); record passing runs too with --record always",
|
|
489
|
+
)
|
|
490
|
+
.argument("[run-id]", "a run under .danwo/runs")
|
|
491
|
+
.option("-c, --config <path>", "config file (default: nearest danwo.config.ts)")
|
|
492
|
+
.action(async (runId: string | undefined, options: { config?: string }) => {
|
|
493
|
+
process.exitCode = await lintRun({ ...(runId !== undefined && { runId }), ...options })
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
program
|
|
497
|
+
.command("serve")
|
|
498
|
+
.description(
|
|
499
|
+
"start the dashboard: runs, flight records, matrix grids and flaky tests from .danwo",
|
|
500
|
+
)
|
|
501
|
+
.option(
|
|
502
|
+
"-p, --port <port>",
|
|
503
|
+
"port on 127.0.0.1 (default 4310; 0 picks a free one)",
|
|
504
|
+
integerAtLeast(0),
|
|
505
|
+
)
|
|
506
|
+
.option("-o, --open", "open the dashboard in the browser")
|
|
507
|
+
.option("-c, --config <path>", "config file (default: nearest danwo.config.ts)")
|
|
508
|
+
.action(async (options: { port?: number; open?: boolean; config?: string }) => {
|
|
509
|
+
const { serveDashboard } = await import("@danwo/dashboard")
|
|
510
|
+
const dashboard = await serveDashboard(options)
|
|
511
|
+
const stop = () => void dashboard.close().then(() => process.exit(0))
|
|
512
|
+
|
|
513
|
+
process.once("SIGINT", stop)
|
|
514
|
+
process.once("SIGTERM", stop)
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
program
|
|
518
|
+
.command("cache")
|
|
519
|
+
.description("list, count or clear the replay cache's recordings")
|
|
520
|
+
.addArgument(new Argument("<action>").choices(["ls", "stats", "clear"]))
|
|
521
|
+
.option("-j, --journey <test-id>", "only the recordings of this test")
|
|
522
|
+
.option("-c, --config <path>", "config file (default: nearest danwo.config.ts)")
|
|
523
|
+
.action(async (action: CacheAction, options: { journey?: string; config?: string }) => {
|
|
524
|
+
process.exitCode = await runCache(action, options)
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
program
|
|
528
|
+
.command("spec")
|
|
529
|
+
.description(
|
|
530
|
+
"print what this danwo provides, generated from its code: API, config, faults, codes",
|
|
531
|
+
)
|
|
532
|
+
.option("--json", "compact JSON instead of text")
|
|
533
|
+
.option(
|
|
534
|
+
"-t, --topic <topic>",
|
|
535
|
+
"one section: api, fixtures, locators, expect, agent, secrets, config, environments, emulators, faults, cli, errors, cache, lint, exports",
|
|
536
|
+
)
|
|
537
|
+
.action(async (options: { json?: boolean; topic?: string }) => {
|
|
538
|
+
const { buildSpec, renderSpec, specJson, specTopic } = await import("@danwo/skill")
|
|
539
|
+
const spec = buildSpec({ commands: cliCommands(program) })
|
|
540
|
+
const shown = options.topic === undefined ? spec : specTopic(spec, options.topic)
|
|
541
|
+
|
|
542
|
+
console.log(options.json ? specJson(shown) : renderSpec(shown))
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* The fidelity checks. They compare the emulators with goldens kept in danwo's own repository, so
|
|
547
|
+
* they are not published: a clone of that repository has them, an installed danwo does not.
|
|
548
|
+
*/
|
|
549
|
+
async function fidelity() {
|
|
550
|
+
try {
|
|
551
|
+
return await import("@danwo/fidelity")
|
|
552
|
+
} catch (error) {
|
|
553
|
+
if (Reflect.get(Object(error), "code") !== "ERR_MODULE_NOT_FOUND") throw error
|
|
554
|
+
|
|
555
|
+
throw new Error(
|
|
556
|
+
"danwo fidelity runs in a clone of the danwo repository, which holds its goldens",
|
|
557
|
+
{
|
|
558
|
+
cause: error,
|
|
559
|
+
},
|
|
560
|
+
)
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** `danwo fidelity login <provider>`: signs a test account in and keeps its refresh token. */
|
|
565
|
+
async function fidelityLogin(
|
|
566
|
+
provider: string,
|
|
567
|
+
root: string,
|
|
568
|
+
options: { redirectUri?: string; port?: number },
|
|
569
|
+
): Promise<number> {
|
|
570
|
+
const { loginCommand } = await fidelity()
|
|
571
|
+
|
|
572
|
+
return loginCommand({
|
|
573
|
+
provider,
|
|
574
|
+
root,
|
|
575
|
+
...(options.redirectUri !== undefined && { redirectUri: options.redirectUri }),
|
|
576
|
+
...(options.port !== undefined && { port: options.port }),
|
|
577
|
+
})
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
program
|
|
581
|
+
.command("fidelity")
|
|
582
|
+
.description(
|
|
583
|
+
"check the emulators against the real providers' sandboxes (with DANWO_FIDELITY_* credentials) or the committed goldens",
|
|
584
|
+
)
|
|
585
|
+
.argument(
|
|
586
|
+
"[providers...]",
|
|
587
|
+
"providers to check (default: all); `login google|microsoft` signs a test account in instead",
|
|
588
|
+
)
|
|
589
|
+
.option(
|
|
590
|
+
"--update-golden",
|
|
591
|
+
"keep the sandbox's (or the API description's) structure as the new golden",
|
|
592
|
+
)
|
|
593
|
+
.option(
|
|
594
|
+
"--from-openapi [file]",
|
|
595
|
+
"make goldens from API descriptions: a file for one provider, or each provider's published one",
|
|
596
|
+
)
|
|
597
|
+
.option("--offline", "compare against the goldens even when credentials are set")
|
|
598
|
+
.option(
|
|
599
|
+
"--keep",
|
|
600
|
+
"leave what live runs make in the sandboxes, listed as kept, instead of undoing it",
|
|
601
|
+
)
|
|
602
|
+
.option(
|
|
603
|
+
"--sweep",
|
|
604
|
+
"list leftovers tagged danwo-fidelity from earlier live runs instead of checking; --yes removes them",
|
|
605
|
+
)
|
|
606
|
+
.option("--yes", "with --sweep, remove the tagged leftovers it lists")
|
|
607
|
+
.option("--root <dir>", "the danwo repository holding fidelity/ (default: the current folder)")
|
|
608
|
+
.option(
|
|
609
|
+
"--redirect-uri <uri>",
|
|
610
|
+
"with login: the redirect URI the OAuth client registers (default: DANWO_FIDELITY_<PROVIDER>_REDIRECT_URI, else http://127.0.0.1:8765/callback)",
|
|
611
|
+
)
|
|
612
|
+
.option("--port <port>", "with login: the port of the default redirect URI", Number)
|
|
613
|
+
.action(
|
|
614
|
+
async (
|
|
615
|
+
providers: string[],
|
|
616
|
+
options: {
|
|
617
|
+
updateGolden?: boolean
|
|
618
|
+
fromOpenapi?: string | true
|
|
619
|
+
offline?: boolean
|
|
620
|
+
keep?: boolean
|
|
621
|
+
sweep?: boolean
|
|
622
|
+
yes?: boolean
|
|
623
|
+
root?: string
|
|
624
|
+
redirectUri?: string
|
|
625
|
+
port?: number
|
|
626
|
+
},
|
|
627
|
+
) => {
|
|
628
|
+
const { fidelityCommand, sweepCommand } = await fidelity()
|
|
629
|
+
const root = options.root ?? process.cwd()
|
|
630
|
+
|
|
631
|
+
if (providers[0] === "login") {
|
|
632
|
+
process.exitCode = await fidelityLogin(providers[1] ?? "", root, options)
|
|
633
|
+
|
|
634
|
+
return
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
process.exitCode =
|
|
638
|
+
options.sweep === true
|
|
639
|
+
? await sweepCommand({ providers, root, yes: options.yes === true })
|
|
640
|
+
: await fidelityCommand({
|
|
641
|
+
providers,
|
|
642
|
+
root,
|
|
643
|
+
...(options.updateGolden !== undefined && { updateGolden: options.updateGolden }),
|
|
644
|
+
...(options.fromOpenapi !== undefined && { fromOpenapi: options.fromOpenapi }),
|
|
645
|
+
...(options.offline !== undefined && { offline: options.offline }),
|
|
646
|
+
...(options.keep !== undefined && { keep: options.keep }),
|
|
647
|
+
})
|
|
648
|
+
},
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
program
|
|
652
|
+
.command("guide")
|
|
653
|
+
.description("list task guides, or print one")
|
|
654
|
+
.argument("[topic]", "a guide's topic; without one, the list")
|
|
655
|
+
.action(async (topic: string | undefined) => {
|
|
656
|
+
const { guideIndex, readGuide } = await import("@danwo/skill")
|
|
657
|
+
|
|
658
|
+
console.log(topic === undefined ? guideIndex() : readGuide(topic).trimEnd())
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
program
|
|
662
|
+
.command("device")
|
|
663
|
+
.description(
|
|
664
|
+
"check this machine for mobile testing: Android (SDK, adb, emulator, rootable images, AVDs, JDK) and, on a Mac, iOS (Xcode, simctl, runtimes, CocoaPods)",
|
|
665
|
+
)
|
|
666
|
+
.addArgument(new Argument("<action>").choices(["doctor"]))
|
|
667
|
+
.addOption(
|
|
668
|
+
new Option("--platform <platform>", "check one platform only").choices(["android", "ios"]),
|
|
669
|
+
)
|
|
670
|
+
.action(async (_action: string, options: { platform?: "android" | "ios" }) => {
|
|
671
|
+
const { deviceDoctor } = await import("@danwo/mobile")
|
|
672
|
+
const checks = await deviceDoctor(undefined, {
|
|
673
|
+
...(options.platform !== undefined && { platform: options.platform }),
|
|
674
|
+
})
|
|
675
|
+
const platforms = [...new Set(checks.map((check) => check.platform))]
|
|
676
|
+
|
|
677
|
+
for (const platform of platforms) {
|
|
678
|
+
if (platforms.length > 1) console.log(platform === "ios" ? "iOS" : "Android")
|
|
679
|
+
for (const check of checks.filter((each) => each.platform === platform))
|
|
680
|
+
console.log(`${check.status.padEnd(4)} ${check.name}: ${check.detail}`)
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/* Without --platform, a machine ready for one platform passes. */
|
|
684
|
+
const broken = platforms.filter((platform) =>
|
|
685
|
+
checks.some((check) => check.platform === platform && check.status === "fail"),
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
process.exitCode = broken.length > 0 && broken.length === platforms.length ? 1 : 0
|
|
689
|
+
})
|
|
690
|
+
|
|
691
|
+
/** What `build-android` and `build-ios` both report. */
|
|
692
|
+
interface AppBuild {
|
|
693
|
+
readonly cached: boolean
|
|
694
|
+
readonly durationMs: number
|
|
695
|
+
readonly log: string
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** The options every build command takes, and the ones a platform adds (`--scheme`, `--variant`). */
|
|
699
|
+
interface BuildFlags {
|
|
700
|
+
readonly force?: boolean
|
|
701
|
+
readonly verbose?: boolean
|
|
702
|
+
readonly [flag: string]: string | boolean | undefined
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Registers `danwo <name> [project]`: `build` makes (or reuses) the build from the project and the
|
|
707
|
+
* flags, `where` says where it is and what it is called. `flags` adds the platform's own options.
|
|
708
|
+
*/
|
|
709
|
+
function buildCommand<B extends AppBuild>(
|
|
710
|
+
name: string,
|
|
711
|
+
description: string,
|
|
712
|
+
flags: readonly (readonly [string, string])[],
|
|
713
|
+
build: (project: string, flags: BuildFlags, onLine?: (line: string) => void) => Promise<B>,
|
|
714
|
+
where: (build: B) => string,
|
|
715
|
+
): void {
|
|
716
|
+
const command = program
|
|
717
|
+
.command(name)
|
|
718
|
+
.description(description)
|
|
719
|
+
.argument(
|
|
720
|
+
"[project]",
|
|
721
|
+
"the app's folder: an Expo app, a native project or a Flutter app (found from what it holds)",
|
|
722
|
+
".",
|
|
723
|
+
)
|
|
724
|
+
.option("--force", "build again even when the sources are unchanged")
|
|
725
|
+
.option("--verbose", "print the build tools' output")
|
|
726
|
+
|
|
727
|
+
for (const [flag, about] of flags) command.option(flag, about)
|
|
728
|
+
|
|
729
|
+
command.action(async (project: string, options: BuildFlags) => {
|
|
730
|
+
const done = await build(
|
|
731
|
+
resolvePath(project),
|
|
732
|
+
options,
|
|
733
|
+
options.verbose === true ? (line: string) => console.log(line) : undefined,
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
console.log(
|
|
737
|
+
done.cached
|
|
738
|
+
? `reused ${where(done)}`
|
|
739
|
+
: `built ${where(done)} in ${Math.round(done.durationMs / 1000)} s (log: ${done.log})`,
|
|
740
|
+
)
|
|
741
|
+
})
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** A string flag's value, if it was given. */
|
|
745
|
+
function flagOf(flags: BuildFlags, name: string): string | undefined {
|
|
746
|
+
const value = flags[name]
|
|
747
|
+
|
|
748
|
+
return typeof value === "string" ? value : undefined
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
buildCommand(
|
|
752
|
+
"build-android",
|
|
753
|
+
"build the danwo build of an app as an APK: an Expo app (release, trusting danwo's per-run CA), a Gradle project (`./gradlew assemble<Variant>`, debug-signed, with danwo's network config added for the build) or a Flutter app (`flutter build apk`); unchanged sources reuse the kept build",
|
|
754
|
+
[["--variant <variant>", "a Gradle project's variant to assemble (default release)"]],
|
|
755
|
+
async (project, flags, onLine) => {
|
|
756
|
+
const variant = flagOf(flags, "variant")
|
|
757
|
+
|
|
758
|
+
return (await import("@danwo/mobile/build")).buildAndroid({
|
|
759
|
+
project,
|
|
760
|
+
force: flags.force === true,
|
|
761
|
+
...(variant !== undefined && { variant }),
|
|
762
|
+
...(onLine !== undefined && { onLine }),
|
|
763
|
+
})
|
|
764
|
+
},
|
|
765
|
+
(build) => (build.package === undefined ? build.apk : `${build.apk} (${build.package})`),
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
buildCommand(
|
|
769
|
+
"build-ios",
|
|
770
|
+
"build the danwo build of an app for the iOS Simulator, unsigned, plain HTTP allowed for the proxy: an Expo app (Release), an Xcode workspace or project (`xcodebuild`, the scheme found or --scheme) or a Flutter app (`flutter build ios --simulator`); unchanged sources reuse the kept build",
|
|
771
|
+
[
|
|
772
|
+
[
|
|
773
|
+
"--scheme <scheme>",
|
|
774
|
+
"a native project's scheme (default: its only one, or the project's name)",
|
|
775
|
+
],
|
|
776
|
+
["--configuration <name>", "a native project's build configuration (default Release)"],
|
|
777
|
+
],
|
|
778
|
+
async (project, flags, onLine) => {
|
|
779
|
+
const scheme = flagOf(flags, "scheme")
|
|
780
|
+
const configuration = flagOf(flags, "configuration")
|
|
781
|
+
|
|
782
|
+
return (await import("@danwo/mobile/build")).buildIos({
|
|
783
|
+
project,
|
|
784
|
+
force: flags.force === true,
|
|
785
|
+
...(scheme !== undefined && { scheme }),
|
|
786
|
+
...(configuration !== undefined && { configuration }),
|
|
787
|
+
...(onLine !== undefined && { onLine }),
|
|
788
|
+
})
|
|
789
|
+
},
|
|
790
|
+
(build) => (build.bundleId === undefined ? build.app : `${build.app} (${build.bundleId})`),
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
try {
|
|
794
|
+
await program.parseAsync()
|
|
795
|
+
} catch (error) {
|
|
796
|
+
process.stderr.write(`danwo: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
797
|
+
process.exitCode = exitCodeForError(error)
|
|
798
|
+
}
|