-
-
Notifications
You must be signed in to change notification settings - Fork 128
Expand file tree
/
Copy pathmockRouter.ts
More file actions
149 lines (137 loc) · 6.71 KB
/
Copy pathmockRouter.ts
File metadata and controls
149 lines (137 loc) · 6.71 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
import { Page } from '@playwright/test'
/**
* Generic HTTP-mock core, shared by every backend the app talks to. It knows nothing about
* stride or any one service — a *service binding* (e.g. tests/fixtures/stride.ts) fixes the
* URL pattern and re-exports a named router; a *scenario* (e.g. tests/vehicleMocks.ts) pairs
* each response body with the exact request URL it answers and hands the list to that router.
*
* Full-URL matching is MANDATORY. A request is served only if its whole URL matches a stub
* exactly (pathname + every query param, order-independent, nothing ignored — deliberately
* unlike the old HAR `urlMatcher` that dropped t/limit/date_from/date_to and rounded floats,
* which let request bugs like a wrong date window or operator pass unnoticed). Anything else
* is recorded as a miss (and 599'd), and the shared `test` fixture fails the test at teardown
* listing the offending URL(s).
*
* Stubs LAYER. Each call merges into one per-page registry keyed by the canonical URL, so a
* later stub for the same URL replaces the earlier one and every other stub survives — which
* is what lets a default catalogue (tests/fixtures/defaults.ts) be installed once for the whole
* suite and a single test override just the URL it is about. Only the FIRST call per service
* installs a Playwright route; the handler reads the registry at request time, so what matters
* is that a stub is registered before the navigation that needs it, not the order the calls
* were made in. (Registering a second `page.route` per pattern would instead shadow the first
* entirely: Playwright evaluates handlers in reverse registration order and ours always
* fulfills, never falls through. The registry is what replaces that broken layering.)
*
* Only a MISS fails a test. Stubs nothing requested are tracked too, but purely as CONTEXT for a
* miss on the same endpoint — a wrong date or limit produces one of each, and printing them
* together shows the URL that was sent next to the one that expected it. An unused stub is never
* an error by itself: a default catalogue or a shared scenario is meant to over-provision, so in
* any one test most of its stubs go unclaimed, and demanding otherwise would tax every test that
* reuses one while catching nothing the miss list and the render assertions do not already catch.
*/
export type RouteStub = {
/** Exact expected request — pathname + full query (order-independent, nothing ignored). */
url: string
body?: unknown
status?: number
}
export const okStub = (url: string, body: unknown): RouteStub => ({ url, body })
/** An error response for an exact URL (exercises react-query retry / load-error paths). */
export const errorStub = (url: string, status = 500): RouteStub => ({ url, status })
/** Canonical form for comparison: pathname + params sorted, NOTHING dropped. */
const canon = (url: string): string => {
const u = new URL(url, 'http://mock')
const params = [...u.searchParams.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}=${v}`)
.join('&')
return `${u.pathname}?${params}`
}
type ServiceState = {
stubs: Map<string, RouteStub>
misses: string[]
/** Stubs that answered at least one request, tracked by identity so that re-stubbing a URL
* starts it over — the replacement has to earn its own claim. */
claimed: Set<RouteStub>
installed: boolean
}
/** Per page, per service pattern. WeakMap so a closed page's state cannot outlive it. */
const servicesByPage = new WeakMap<Page, Map<string, ServiceState>>()
const stateFor = (page: Page, pattern: RegExp): ServiceState => {
let services = servicesByPage.get(page)
if (!services) {
services = new Map<string, ServiceState>()
servicesByPage.set(page, services)
}
let state = services.get(pattern.source)
if (!state) {
state = { stubs: new Map(), misses: [], claimed: new Set(), installed: false }
services.set(pattern.source, state)
}
return state
}
/** Read and clear the requests that matched no stub for this page (across all services). */
export const takeServiceMisses = (page: Page): string[] => {
const services = servicesByPage.get(page)
if (!services) return []
const misses = [...services.values()].flatMap((state) => state.misses)
services.forEach((state) => (state.misses.length = 0))
return misses
}
/**
* The registered-but-never-requested stubs for this page, as canonical URLs. Diagnostic only —
* the shared `test` fixture reads this after a miss and prints the entries for the SAME endpoint,
* which is what turns "unmocked request" into a readable diff: the request that was sent above
* the stub that expected it, differing in just the param that drifted.
*/
export const unclaimedStubs = (page: Page): string[] => {
const services = servicesByPage.get(page)
if (!services) return []
return [...services.values()].flatMap(({ stubs, claimed }) =>
[...stubs.entries()].filter(([, stub]) => !claimed.has(stub)).map(([url]) => url),
)
}
/**
* Intercept every request whose URL matches `pattern` and answer it from `stubs` by exact
* URL; unmatched requests are 599'd and recorded (see takeServiceMisses). Bind one of these
* per service (stride.ts, and later backend.ts). Different patterns coexist on one page
* because Playwright only invokes a route handler for requests matching its own pattern, so
* a stride request never reaches the backend route and vice versa.
*
* Call it as often as you like for the same service: stubs merge, last one wins per URL.
*/
export async function routeService(page: Page, pattern: RegExp, stubs: RouteStub[]) {
const state = stateFor(page, pattern)
for (const stub of stubs) state.stubs.set(canon(stub.url), stub)
if (state.installed) return
state.installed = true
await page.route(pattern, (route) => {
const stub = state.stubs.get(canon(route.request().url()))
if (stub) state.claimed.add(stub)
if (!stub) {
state.misses.push(route.request().url())
return route.fulfill({
status: 599,
contentType: 'text/plain',
body: 'unmocked request',
})
}
if (stub.status && stub.status >= 400) {
return route.fulfill({ status: stub.status, contentType: 'application/json', body: '{}' })
}
return route.fulfill({
status: stub.status ?? 200,
contentType: 'application/json',
body: JSON.stringify(stub.body),
})
})
}
/**
* Drop stubs a lower layer registered, so those URLs become misses again. This is how a test
* asserts a request must NOT be issued: remove the default and the test fails at teardown if
* the app asks for it anyway.
*/
export const unrouteStubs = (page: Page, pattern: RegExp, urls: string[]) => {
const state = stateFor(page, pattern)
urls.forEach((url) => state.stubs.delete(canon(url)))
}