@danwo/playwright 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 +13 -2
- package/bin/danwo-playwright.mjs +8 -0
- package/dist/types/bin.d.ts +1 -0
- package/dist/types/environment.d.ts +24 -0
- package/dist/types/fixture.d.ts +57 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/matrix.d.ts +19 -0
- package/dist/types/protocol.d.ts +96 -0
- package/dist/types/record.d.ts +20 -0
- package/dist/types/spawn.d.ts +13 -0
- package/package.json +47 -4
- package/src/bin.ts +51 -0
- package/src/environment.ts +41 -0
- package/src/fixture.ts +306 -0
- package/src/index.ts +19 -0
- package/src/matrix.ts +181 -0
- package/src/protocol.ts +80 -0
- package/src/record.ts +74 -0
- package/src/spawn.ts +73 -0
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
|
+
}
|
package/src/protocol.ts
ADDED
|
@@ -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
|
+
}
|