@danwo/playwright 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/fixture.ts ADDED
@@ -0,0 +1,306 @@
1
+ /**
2
+ * The Playwright Test fixture: every worker starts the project's danwo environment once (app,
3
+ * emulators, proxy) and launches its browser behind that proxy; every test gets `danwo` to reach
4
+ * the emulators, read what the app sent, and arm faults; and a test's invariant runs after its
5
+ * body, whichever way the body went.
6
+ */
7
+ import { appendFile } from "node:fs/promises"
8
+ import { dirname, relative } from "node:path"
9
+ import { stripVTControlCharacters } from "node:util"
10
+
11
+ import type { ServiceDelivery } from "@danwo/matrix"
12
+ import type { Exchange, Proxy } from "@danwo/proxy"
13
+ import { proxyContextOptions, proxyLaunchOptions } from "@danwo/runner"
14
+ import { FaultBench, type NamedFault } from "@danwo/runner/runner"
15
+ import {
16
+ test as base,
17
+ type PlaywrightTestArgs,
18
+ type PlaywrightTestOptions,
19
+ type PlaywrightWorkerArgs,
20
+ type PlaywrightWorkerOptions,
21
+ type TestInfo,
22
+ type TestType,
23
+ } from "@playwright/test"
24
+
25
+ import { type EnvironmentFactory, startWorkerSetup, type WorkerSetup } from "./environment.ts"
26
+ import {
27
+ OUTCOME_FILE,
28
+ TRIAL_FAULT,
29
+ type TestOutcome,
30
+ deliveryLine,
31
+ exchangeLine,
32
+ } from "./protocol.ts"
33
+ import {
34
+ attachRecord,
35
+ deliveriesSince,
36
+ type EvidenceMark,
37
+ exchangesSince,
38
+ markOf,
39
+ type Verdict,
40
+ } from "./record.ts"
41
+
42
+ /** What a test reaches danwo through. */
43
+ export interface DanwoHandle {
44
+ /** The environment's fixtures by name: emulators (`stripe`, `slack`, …) and helpers. */
45
+ readonly fixtures: Readonly<Record<string, unknown>>
46
+ /** The proxy the browser and the app go through, when the environment has one. */
47
+ readonly proxy: Proxy | undefined
48
+ /** Lets pending background work finish, e.g. webhooks waiting on the emulator's clock. */
49
+ settle(): Promise<void>
50
+ /** Every call the app made through the proxy since this test began, bodies included. */
51
+ exchanges(): readonly Exchange[]
52
+ /** Every webhook delivery attempt since this test began. */
53
+ deliveries(): readonly ServiceDelivery[]
54
+ /**
55
+ * Arms one fault by its matrix label, e.g. `duplicate on webhook checkout.session.completed` or
56
+ * `status 500 on POST api.stripe.com/v1/checkout/sessions`; it fires on the next matching call.
57
+ */
58
+ inject(fault: string): NamedFault
59
+ /** Disarms every fault this test armed; returns how many had not fired. */
60
+ clearFaults(): number
61
+ }
62
+
63
+ /** The `danwo` fixture: the handle plus the environment's fixtures under their own names. */
64
+ export type Danwo<F = Readonly<Record<string, unknown>>> = DanwoHandle & F
65
+
66
+ export interface InvariantArgs<F> {
67
+ readonly danwo: Danwo<F>
68
+ readonly baseURL: string | undefined
69
+ }
70
+
71
+ /** What must hold after the test, however its body went: read from the app and the emulators. */
72
+ export type Invariant<F = Readonly<Record<string, unknown>>> = (
73
+ args: InvariantArgs<F>,
74
+ ) => Promise<void> | void
75
+
76
+ export interface DanwoTestFixtures<F> {
77
+ danwo: Danwo<F>
78
+ /** The test's invariant; set with `test.invariant(fn)` or `test.use({ invariant })`. */
79
+ invariant: Invariant<F> | undefined
80
+ }
81
+
82
+ export interface DanwoWorkerFixtures {
83
+ /** A danwo config file whose `environment()` the worker starts; default the nearest one. */
84
+ danwoConfig: string | undefined
85
+ /** An environment to start instead of the config's. */
86
+ danwoEnvironment: EnvironmentFactory | undefined
87
+ danwoWorker: WorkerSetup
88
+ }
89
+
90
+ export type DanwoTest<F = Readonly<Record<string, unknown>>> = TestType<
91
+ PlaywrightTestArgs & PlaywrightTestOptions & DanwoTestFixtures<F>,
92
+ PlaywrightWorkerArgs & PlaywrightWorkerOptions & DanwoWorkerFixtures
93
+ > & {
94
+ /** Declares the invariant of every test in this file or describe block. */
95
+ invariant(fn: Invariant<F>): void
96
+ }
97
+
98
+ /** Where `danwoConfig` resolves from: the Playwright config's folder, else the working folder. */
99
+ function projectRoot(configFile: string | undefined): string {
100
+ return configFile === undefined ? process.cwd() : dirname(configFile)
101
+ }
102
+
103
+ /** `text` without terminal colours, as Playwright's expect messages carry them. */
104
+ function plain(text: string): string {
105
+ return stripVTControlCharacters(text)
106
+ }
107
+
108
+ function messageOf(error: unknown): string {
109
+ return plain(error instanceof Error ? error.message : String(error))
110
+ }
111
+
112
+ function handleOf(setup: WorkerSetup, mark: EvidenceMark, bench: FaultBench): Danwo {
113
+ const { env } = setup
114
+
115
+ return {
116
+ ...env.fixtures,
117
+ fixtures: env.fixtures ?? {},
118
+ proxy: env.proxy,
119
+ settle: async () => {
120
+ await env.settle?.()
121
+ await env.proxy?.settled()
122
+ },
123
+ exchanges: () => exchangesSince(env, mark),
124
+ deliveries: () => deliveriesSince(env, mark),
125
+ inject: (fault) => bench.arm(fault),
126
+ clearFaults: () => bench.clear(),
127
+ }
128
+ }
129
+
130
+ async function runInvariant(
131
+ invariant: Invariant | undefined,
132
+ args: InvariantArgs<Readonly<Record<string, unknown>>>,
133
+ ): Promise<string | null> {
134
+ if (invariant === undefined) return null
135
+
136
+ try {
137
+ await invariant(args)
138
+
139
+ return null
140
+ } catch (error) {
141
+ return messageOf(error)
142
+ }
143
+ }
144
+
145
+ /** The spec file line the body's failure went through, read from the error's stack. */
146
+ function failureLine(testInfo: TestInfo): number | null {
147
+ const stack = testInfo.error?.stack ?? ""
148
+ const at = stack.split("\n").find((frame) => frame.includes(testInfo.file))
149
+ const line = at === undefined ? undefined : /:(\d+):\d+\)?$/.exec(at)?.[1]
150
+
151
+ return line === undefined ? null : Number(line)
152
+ }
153
+
154
+ /** Appends the test's outcome for `danwo-playwright matrix`, when it is the one running. */
155
+ async function reportOutcome(
156
+ testInfo: TestInfo,
157
+ setup: WorkerSetup,
158
+ mark: EvidenceMark,
159
+ result: { verdict: Verdict; declared: boolean; record: string | null },
160
+ ): Promise<void> {
161
+ const file = process.env[OUTCOME_FILE]
162
+
163
+ if (file === undefined) return
164
+
165
+ const outcome: TestOutcome = {
166
+ title: testInfo.titlePath.join(" › "),
167
+ file: relative(projectRoot(testInfo.config.configFile), testInfo.file).split("\\").join("/"),
168
+ body: testInfo.status ?? "failed",
169
+ bodyError: result.verdict.bodyError,
170
+ invariant: result.declared ? { error: result.verdict.invariantError } : null,
171
+ line: failureLine(testInfo),
172
+ exchanges: exchangesSince(setup.env, mark).map(exchangeLine),
173
+ deliveries: deliveriesSince(setup.env, mark).map(deliveryLine),
174
+ record: result.record,
175
+ }
176
+
177
+ await appendFile(file, `${JSON.stringify(outcome)}\n`)
178
+ }
179
+
180
+ const fixtures = base.extend<
181
+ DanwoTestFixtures<Readonly<Record<string, unknown>>>,
182
+ DanwoWorkerFixtures
183
+ >({
184
+ danwoConfig: [undefined, { option: true, scope: "worker" }],
185
+ danwoEnvironment: [undefined, { option: true, scope: "worker" }],
186
+ invariant: [undefined, { option: true }],
187
+ danwoWorker: [
188
+ async ({ danwoConfig, danwoEnvironment }, use, workerInfo) => {
189
+ const setup = await startWorkerSetup({
190
+ config: danwoConfig,
191
+ environment: danwoEnvironment,
192
+ cwd: projectRoot(workerInfo.config.configFile),
193
+ workerIndex: workerInfo.parallelIndex,
194
+ })
195
+
196
+ try {
197
+ await use(setup)
198
+ } finally {
199
+ await setup.env.close()
200
+ }
201
+ },
202
+ { scope: "worker" },
203
+ ],
204
+ /*
205
+ * The suite's own Playwright launches the browser, so its browser builds, its tracing and its
206
+ * screenshots all match the `@playwright/test` the project installed; danwo only adds its proxy
207
+ * and, for Chromium, the flag that trusts the proxy's CA.
208
+ */
209
+ browser: [
210
+ async ({ danwoWorker, playwright, browserName, launchOptions }, use) => {
211
+ const proxy = danwoWorker.env.proxy
212
+ const behind = proxyLaunchOptions(
213
+ proxy && { url: proxy.url, caSpki: proxy.caSpki },
214
+ browserName,
215
+ )
216
+ const browser = await playwright[browserName].launch({
217
+ ...launchOptions,
218
+ headless: launchOptions.headless ?? true,
219
+ ...(behind.proxy && { proxy: behind.proxy }),
220
+ args: [...(launchOptions.args ?? []), ...(behind.args ?? [])],
221
+ })
222
+
223
+ try {
224
+ await use(browser)
225
+ } finally {
226
+ await browser.close()
227
+ }
228
+ },
229
+ { scope: "worker" },
230
+ ],
231
+ /*
232
+ * Firefox and WebKit take no flag that trusts one CA, so their contexts behind danwo's proxy
233
+ * ignore certificate errors: the proxy is then their only TLS peer, and it checks each real
234
+ * site's certificate itself.
235
+ */
236
+ ignoreHTTPSErrors: async ({ ignoreHTTPSErrors, browserName, danwoWorker }, use) => {
237
+ const proxy = danwoWorker.env.proxy
238
+ const trust = proxyContextOptions(
239
+ proxy && { url: proxy.url, caSpki: proxy.caSpki },
240
+ browserName,
241
+ )
242
+
243
+ await use(trust.ignoreHTTPSErrors ?? ignoreHTTPSErrors)
244
+ },
245
+ baseURL: async ({ baseURL, danwoWorker }, use) => {
246
+ await use(danwoWorker.env.baseUrl ?? baseURL)
247
+ },
248
+ danwo: [
249
+ async ({ danwoWorker, invariant, baseURL }, use, testInfo) => {
250
+ const mark = markOf(danwoWorker.env)
251
+ const bench = new FaultBench(danwoWorker.env.proxy, danwoWorker.env.webhooks)
252
+ const fault = process.env[TRIAL_FAULT] ?? null
253
+
254
+ if (fault !== null) bench.arm(fault)
255
+
256
+ const danwo = handleOf(danwoWorker, mark, bench)
257
+
258
+ await use(danwo)
259
+ await danwo.settle()
260
+
261
+ const verdict: Verdict = {
262
+ bodyError: testInfo.error?.message === undefined ? null : plain(testInfo.error.message),
263
+ invariantError: await runInvariant(invariant, { danwo, baseURL }),
264
+ fault,
265
+ }
266
+ const failed = verdict.bodyError !== null || verdict.invariantError !== null
267
+ const record = failed ? await attachRecord(danwoWorker, mark, verdict, testInfo) : null
268
+
269
+ await reportOutcome(testInfo, danwoWorker, mark, {
270
+ verdict,
271
+ declared: invariant !== undefined,
272
+ record,
273
+ })
274
+ bench.clear()
275
+
276
+ if (verdict.invariantError !== null) throw new Error(`invariant: ${verdict.invariantError}`)
277
+ },
278
+ { auto: true },
279
+ ],
280
+ })
281
+
282
+ /**
283
+ * Declares the invariant of every test in the current file or describe block. Playwright reads a
284
+ * function given to `use` as a fixture, so the invariant is handed over by one.
285
+ */
286
+ function declareInvariant(fn: Invariant): void {
287
+ fixtures.use({
288
+ invariant: async ({ danwoWorker: _worker }, use) => {
289
+ await use(fn)
290
+ },
291
+ })
292
+ }
293
+
294
+ /** `test` from `@playwright/test`, with danwo's environment, `danwo` fixture and invariants. */
295
+ export const test: DanwoTest = Object.assign(fixtures, { invariant: declareInvariant })
296
+
297
+ /**
298
+ * `test` with the environment's fixtures typed, e.g. `testWith<StripeFixtures>()` gives
299
+ * `danwo.stripe`. The fixtures are whatever the environment returns; the type only names them.
300
+ */
301
+ export function testWith<F>(): DanwoTest<F> {
302
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the fixtures exist at run time; F only names the environment's fixtures for the caller
303
+ return test as unknown as DanwoTest<F>
304
+ }
305
+
306
+ export { expect } from "@playwright/test"
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ /*
2
+ * danwo for Playwright Test suites: the project's environment (app, emulators, proxy) per worker,
3
+ * a `danwo` fixture with the emulators and live faults, invariants, and flight records attached to
4
+ * failed tests. `danwo-playwright matrix` runs a spec once clean and once per planned fault.
5
+ */
6
+ export type { EnvironmentFactory, WorkerSetup } from "./environment.ts"
7
+ export {
8
+ type Danwo,
9
+ type DanwoHandle,
10
+ type DanwoTest,
11
+ type DanwoTestFixtures,
12
+ type DanwoWorkerFixtures,
13
+ expect,
14
+ type Invariant,
15
+ type InvariantArgs,
16
+ test,
17
+ testWith,
18
+ } from "./fixture.ts"
19
+ export { judgeTrial, type PlaywrightMatrixOptions, runPlaywrightMatrix } from "./matrix.ts"
package/src/matrix.ts ADDED
@@ -0,0 +1,181 @@
1
+ /**
2
+ * `danwo-playwright matrix`: one Playwright test, run once clean and then once per fault planned
3
+ * from the calls and webhooks the clean run caused. Every trial is its own `playwright test`
4
+ * process, so each starts a fresh environment; the fixture arms the trial's fault and reports what
5
+ * happened. A faulted trial is judged by the test's invariant (its body may well break under the
6
+ * fault); a test without an invariant must pass its body under every fault.
7
+ */
8
+ import { mkdtemp, readFile, rm } from "node:fs/promises"
9
+ import { tmpdir } from "node:os"
10
+ import { join, relative } from "node:path"
11
+
12
+ import {
13
+ checkFaultFired,
14
+ type FaultMatrixResult,
15
+ formatMatrix,
16
+ type PlannedFault,
17
+ planFaults,
18
+ type RunOutcome,
19
+ } from "@danwo/matrix"
20
+ import { DEFAULT_WEBHOOK_FAULTS, defaultOutboundFaults, loadConfig } from "@danwo/runner/runner"
21
+
22
+ import { readOutcomes, type TestOutcome } from "./protocol.ts"
23
+ import { runPlaywright } from "./spawn.ts"
24
+
25
+ export interface PlaywrightMatrixOptions {
26
+ /** The spec file; with `grep`, it must select exactly one test. */
27
+ readonly spec: string
28
+ readonly grep?: string | undefined
29
+ /** A regular expression over planned fault labels: only the faults it matches run. */
30
+ readonly faults?: string | undefined
31
+ /** The Playwright config file, when it is not the default one. */
32
+ readonly config?: string | undefined
33
+ /** The danwo config whose `matrix` settings to use; default the nearest one. */
34
+ readonly danwoConfig?: string | undefined
35
+ readonly cwd?: string | undefined
36
+ readonly write?: ((line: string) => void) | undefined
37
+ }
38
+
39
+ /** An error in one report line: its first line, plus what was expected and received. */
40
+ function firstLine(text: string): string {
41
+ const lines = text.split("\n").map((line) => line.trim())
42
+ const values = lines.filter((line) => /^(Expected|Received)\b/.test(line))
43
+
44
+ return [lines[0] ?? text, ...values].join(" · ")
45
+ }
46
+
47
+ function bodyNote(outcome: TestOutcome): string[] {
48
+ if (outcome.body === "passed" || outcome.bodyError === null) return []
49
+
50
+ const where = outcome.line === null ? "" : ` at ${outcome.file}:${outcome.line}`
51
+
52
+ return [`body stopped${where}: ${firstLine(outcome.bodyError)}`]
53
+ }
54
+
55
+ function invariantFailure(outcome: TestOutcome): string | null {
56
+ const error = outcome.invariant?.error ?? null
57
+
58
+ return error === null ? null : `invariant: ${firstLine(error)}`
59
+ }
60
+
61
+ function bodyFailure(outcome: TestOutcome): string | null {
62
+ if (outcome.body === "passed") return null
63
+
64
+ return firstLine(outcome.bodyError ?? outcome.body)
65
+ }
66
+
67
+ /** Turns one trial's outcome into a matrix run: the clean run needs body and invariant to hold. */
68
+ export function judgeTrial(fault: PlannedFault | null, outcome: TestOutcome): RunOutcome {
69
+ const judgedByInvariant = fault !== null && outcome.invariant !== null
70
+ const error = judgedByInvariant
71
+ ? invariantFailure(outcome)
72
+ : (bodyFailure(outcome) ?? invariantFailure(outcome))
73
+
74
+ return checkFaultFired({
75
+ fault,
76
+ status: error === null ? "passed" : "failed",
77
+ error,
78
+ exchanges: outcome.exchanges,
79
+ deliveries: outcome.deliveries,
80
+ notes: judgedByInvariant ? bodyNote(outcome) : [],
81
+ record: outcome.record ?? undefined,
82
+ })
83
+ }
84
+
85
+ /** A trial's output folder name: its place in the run and its fault, e.g. `03-reset-on-post-…`. */
86
+ function trialFolder(fault: PlannedFault | null, index: number): string {
87
+ const slug = (fault?.label ?? "clean run")
88
+ .toLowerCase()
89
+ .replace(/[^a-z0-9]+/g, "-")
90
+ .replace(/^-|-$/g, "")
91
+ .slice(0, 60)
92
+
93
+ return `${String(index).padStart(2, "0")}-${slug}`
94
+ }
95
+
96
+ function environmentError(fault: PlannedFault | null, error: string): RunOutcome {
97
+ return { fault, status: "environment-error", error, exchanges: [], deliveries: [], notes: [] }
98
+ }
99
+
100
+ /** Runs one trial in its own `playwright test` process and judges it. */
101
+ async function trial(
102
+ options: PlaywrightMatrixOptions,
103
+ fault: PlannedFault | null,
104
+ index: number,
105
+ ): Promise<RunOutcome> {
106
+ const cwd = options.cwd ?? process.cwd()
107
+ const dir = await mkdtemp(join(tmpdir(), "danwo-playwright-"))
108
+ const outcomeFile = join(dir, "outcome.jsonl")
109
+ const outputDir = join("test-results", "danwo-matrix", trialFolder(fault, index))
110
+
111
+ try {
112
+ const output = await runPlaywright({ ...options, outcomeFile, outputDir, fault: fault?.label })
113
+ const outcomes = readOutcomes(await readFile(outcomeFile, "utf8").catch(() => ""))
114
+ const [outcome] = outcomes
115
+
116
+ if (outcomes.length !== 1 || outcome === undefined) {
117
+ return environmentError(
118
+ fault,
119
+ `the spec ran ${outcomes.length} danwo tests; select exactly one with --grep\n${output}`,
120
+ )
121
+ }
122
+
123
+ return judgeTrial(fault, {
124
+ ...outcome,
125
+ record: outcome.record === null ? null : relative(cwd, outcome.record).split("\\").join("/"),
126
+ })
127
+ } finally {
128
+ await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
129
+ }
130
+ }
131
+
132
+ /** The faults to try, from the danwo config's `matrix` settings or danwo's defaults. */
133
+ async function catalogue(options: PlaywrightMatrixOptions) {
134
+ const { config } = await loadConfig(options.cwd ?? process.cwd(), options.danwoConfig)
135
+
136
+ return {
137
+ outbound: config.matrix?.outboundFaults ?? defaultOutboundFaults,
138
+ webhook: config.matrix?.webhookFaults ?? DEFAULT_WEBHOOK_FAULTS,
139
+ }
140
+ }
141
+
142
+ function selected(plan: readonly PlannedFault[], filter: string | undefined): PlannedFault[] {
143
+ if (filter === undefined) return [...plan]
144
+
145
+ const pattern = new RegExp(filter)
146
+
147
+ return plan.filter((planned) => pattern.test(planned.label))
148
+ }
149
+
150
+ function failedAny(result: FaultMatrixResult): boolean {
151
+ return [result.clean, ...result.runs].some((run) => run.status !== "passed")
152
+ }
153
+
154
+ /** Runs the matrix and prints its report; resolves with the exit code. */
155
+ export async function runPlaywrightMatrix(options: PlaywrightMatrixOptions): Promise<number> {
156
+ const write = options.write ?? ((line: string) => process.stdout.write(`${line}\n`))
157
+ const clean = await trial(options, null, 0)
158
+
159
+ if (clean.status !== "passed") {
160
+ write(formatMatrix({ clean, runs: [] }))
161
+ write("the clean run did not pass, so no faults were planned")
162
+
163
+ return 1
164
+ }
165
+
166
+ const faults = await catalogue(options)
167
+ const planned = planFaults(clean, faults.outbound, faults.webhook)
168
+ const plan = selected(planned, options.faults)
169
+
170
+ write(`clean run passed; ${plan.length} of ${planned.length} planned faults to run`)
171
+
172
+ const runs: RunOutcome[] = []
173
+
174
+ for (const [index, fault] of plan.entries()) runs.push(await trial(options, fault, index + 1))
175
+
176
+ const result = { clean, runs }
177
+
178
+ write(formatMatrix(result))
179
+
180
+ return failedAny(result) ? 1 : 0
181
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * How `danwo-playwright matrix` and the fixture talk across the Playwright process they share
3
+ * nothing else with: the matrix sets two environment variables, the fixture appends one JSON line
4
+ * per test to the outcome file.
5
+ */
6
+ import type { ServiceDelivery } from "@danwo/matrix"
7
+ import type { Exchange } from "@danwo/proxy"
8
+ import { z } from "@danwo/runner/runner"
9
+
10
+ /** Where the fixture appends each test's outcome; set by the matrix for every trial. */
11
+ export const OUTCOME_FILE = "DANWO_PLAYWRIGHT_OUTCOME"
12
+
13
+ /** The fault a trial arms, by its matrix label; absent for the clean trial. */
14
+ export const TRIAL_FAULT = "DANWO_PLAYWRIGHT_FAULT"
15
+
16
+ const ExchangeLine = z.object({
17
+ disposition: z.enum(["routed", "passthrough", "blocked"]),
18
+ method: z.string(),
19
+ host: z.string(),
20
+ path: z.string(),
21
+ startedAt: z.string(),
22
+ id: z.string(),
23
+ fault: z.string().nullable(),
24
+ status: z.number().nullable(),
25
+ durationMs: z.number().nullable(),
26
+ aborted: z.boolean(),
27
+ })
28
+
29
+ const DeliveryLine = z.object({
30
+ messageId: z.number(),
31
+ topic: z.string(),
32
+ endpointId: z.string(),
33
+ attempt: z.number(),
34
+ at: z.string(),
35
+ status: z.number().nullable(),
36
+ error: z.string().nullable(),
37
+ durationMs: z.number(),
38
+ delivered: z.boolean(),
39
+ fault: z.string().nullable(),
40
+ service: z.string().exactOptional(),
41
+ })
42
+
43
+ /** One test's outcome as the fixture saw it. */
44
+ const OutcomeLine = z.object({
45
+ title: z.string(),
46
+ file: z.string(),
47
+ /** Where the body ended up: Playwright's status for the test before the invariant ran. */
48
+ body: z.enum(["passed", "failed", "timedOut", "skipped", "interrupted"]),
49
+ bodyError: z.string().nullable(),
50
+ /** Null when the test declares no invariant. */
51
+ invariant: z.object({ error: z.string().nullable() }).nullable(),
52
+ /** The test file line the failure went through, when Playwright knows it. */
53
+ line: z.number().nullable(),
54
+ exchanges: z.array(ExchangeLine),
55
+ deliveries: z.array(DeliveryLine),
56
+ /** The flight record attached to the test's report, when one was. */
57
+ record: z.string().nullable(),
58
+ })
59
+
60
+ export type TestOutcome = z.infer<typeof OutcomeLine>
61
+
62
+ /** An exchange as the outcome file carries it: everything but its bodies. */
63
+ export function exchangeLine(exchange: Exchange): z.infer<typeof ExchangeLine> {
64
+ const { request: _request, response: _response, ...call } = exchange
65
+
66
+ return call
67
+ }
68
+
69
+ /** A delivery as the outcome file carries it. */
70
+ export function deliveryLine(delivery: ServiceDelivery): z.infer<typeof DeliveryLine> {
71
+ return delivery
72
+ }
73
+
74
+ /** Every outcome line in `text`, in the order the tests finished. */
75
+ export function readOutcomes(text: string): TestOutcome[] {
76
+ return text
77
+ .split("\n")
78
+ .filter((line) => line.trim() !== "")
79
+ .map((line) => OutcomeLine.parse(JSON.parse(line)))
80
+ }
package/src/record.ts ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * What a failed Playwright test leaves behind for danwo: the calls the app made through the proxy,
3
+ * the webhooks delivered, and the emulators' state, redacted the way danwo's own flight records
4
+ * are, attached to the test's report beside Playwright's trace.
5
+ */
6
+ import { writeFile } from "node:fs/promises"
7
+
8
+ import { deliveriesOf } from "@danwo/matrix"
9
+ import {
10
+ createRedactor,
11
+ environmentState,
12
+ redactExchange,
13
+ type WorkerEnvironment,
14
+ } from "@danwo/runner/runner"
15
+ import type { TestInfo } from "@playwright/test"
16
+
17
+ import type { WorkerSetup } from "./environment.ts"
18
+
19
+ /** Where a test's evidence starts: counts of what the environment had seen before it. */
20
+ export interface EvidenceMark {
21
+ readonly exchanges: number
22
+ readonly deliveries: number
23
+ }
24
+
25
+ export function markOf(env: WorkerEnvironment): EvidenceMark {
26
+ return {
27
+ exchanges: env.proxy?.exchanges().length ?? 0,
28
+ deliveries: deliveriesOf(env.webhooks).length,
29
+ }
30
+ }
31
+
32
+ /** The exchanges since `mark`, unredacted (bodies included) for the test's own use. */
33
+ export function exchangesSince(env: WorkerEnvironment, mark: EvidenceMark) {
34
+ return env.proxy?.exchanges().slice(mark.exchanges) ?? []
35
+ }
36
+
37
+ /** The webhook delivery attempts since `mark`. */
38
+ export function deliveriesSince(env: WorkerEnvironment, mark: EvidenceMark) {
39
+ return deliveriesOf(env.webhooks).slice(mark.deliveries)
40
+ }
41
+
42
+ export interface Verdict {
43
+ readonly bodyError: string | null
44
+ readonly invariantError: string | null
45
+ readonly fault: string | null
46
+ }
47
+
48
+ /** Writes the test's flight record next to its output and attaches it; returns its path. */
49
+ export async function attachRecord(
50
+ setup: WorkerSetup,
51
+ mark: EvidenceMark,
52
+ verdict: Verdict,
53
+ testInfo: TestInfo,
54
+ ): Promise<string> {
55
+ const redactor = createRedactor(setup.vault.redact)
56
+ const record = {
57
+ test: testInfo.titlePath.join(" › "),
58
+ status: testInfo.status ?? "failed",
59
+ fault: verdict.fault,
60
+ bodyError: verdict.bodyError === null ? null : redactor.text(verdict.bodyError),
61
+ invariantError: verdict.invariantError === null ? null : redactor.text(verdict.invariantError),
62
+ exchanges: exchangesSince(setup.env, mark).map((exchange) =>
63
+ redactExchange(exchange, redactor),
64
+ ),
65
+ deliveries: deliveriesSince(setup.env, mark),
66
+ state: environmentState(setup.env, redactor),
67
+ }
68
+ const path = testInfo.outputPath("danwo-flight-record.json")
69
+
70
+ await writeFile(path, `${JSON.stringify(record, null, 2)}\n`)
71
+ await testInfo.attach("danwo flight record", { path, contentType: "application/json" })
72
+
73
+ return path
74
+ }