mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 10:31:10 +00:00
[AZ-456] Test infrastructure: Vitest + MSW + Playwright + scripts
Scaffolds the Blackbox test project per AZ-456 / environment.md across
the three profiles:
- fast : Vitest 3.x + jsdom + MSW 2.x + RTL/jest-dom; tests/setup.ts
boots the MSW Node server with onUnhandledRequest:'error',
afterEach resets handlers, clears bearer + navigate-to-login
spy. Default handlers ship for every suite service plus OWM
and tile stand-ins. Fixtures mirror seed_* in test-data.md.
- e2e : Playwright ^1.49 with chromium + firefox projects against the
suite docker-compose stack; owm-stub + tile-stub Bun servers,
playwright-runner image, seeds.sql for the test-db.
- static: scripts/run-tests.sh extended — tsc --noEmit (test config),
vite build, ripgrep checks (with grep -r fallback), CSV
report at test-output/static-report.csv per AC-7 columns.
Smoke tests cover AC-3, AC-4 (fast, 5 tests, PASS) and AC-1, AC-2,
AC-5, AC-8 (e2e, gated by Risk 4 docker availability). Static profile
(13 checks) PASS — STC-SEC1 (no literal OWM key) lifted from
QUARANTINE per AZ-447 with a narrowed pattern.
Files:
+24 tests/**, +10 e2e/**, +vitest.config.ts, +tsconfig.test.json
~package.json (test scripts + devDeps for vitest, @testing-library/*,
msw, @playwright/test, jsdom, @types/node, @vitest/coverage-v8)
~scripts/run-tests.sh, scripts/run-performance-tests.sh — switched
RESULTS_DIR to test-output/, compose path to project-local
~.gitignore — added /test-output/
Verification:
bun run test:fast → 11 / 11 PASS
./scripts/run-tests.sh → static 13/13 + fast 11/11 PASS, exit 0
Tracker: AZ-456 → In Testing.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { http } from 'msw'
|
||||
import { jsonResponse, noContent, paginate } from '../helpers'
|
||||
import { seedUsers, opAlice } from '../../fixtures/seed_users'
|
||||
import { seedClasses } from '../../fixtures/seed_classes'
|
||||
|
||||
// Default `/api/admin/*` handlers — auth round-trip, users, classes-write,
|
||||
// system settings. Tests override per-scenario via `server.use(...)`.
|
||||
|
||||
const SEED_BEARER = 'test-bearer-default'
|
||||
|
||||
export const adminHandlers = [
|
||||
http.post('/api/admin/auth/login', async ({ request }) => {
|
||||
const body = (await request.json().catch(() => ({}))) as { email?: string; password?: string }
|
||||
const user = seedUsers.find((u) => u.email === body.email) ?? opAlice
|
||||
return new Response(JSON.stringify({ token: SEED_BEARER, user }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// AC-03 contract — refresh cookie is HttpOnly + Secure + SameSite=Strict.
|
||||
'Set-Cookie': 'refreshToken=test-refresh; HttpOnly; Secure; SameSite=Strict; Path=/api/admin/auth',
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
http.post('/api/admin/auth/refresh', () => {
|
||||
return jsonResponse({ token: SEED_BEARER })
|
||||
}),
|
||||
|
||||
http.post('/api/admin/auth/logout', () => noContent()),
|
||||
|
||||
http.get('/api/admin/users/me', () => jsonResponse(opAlice)),
|
||||
|
||||
http.get('/api/admin/users', () => jsonResponse(paginate(seedUsers))),
|
||||
|
||||
http.get('/api/admin/users/:id', ({ params }) => {
|
||||
const user = seedUsers.find((u) => u.id === params.id)
|
||||
if (!user) return new Response(null, { status: 404 })
|
||||
return jsonResponse(user)
|
||||
}),
|
||||
|
||||
http.get('/api/admin/classes', () => jsonResponse(seedClasses)),
|
||||
|
||||
http.post('/api/admin/classes', async ({ request }) => {
|
||||
const body = await request.json()
|
||||
return jsonResponse(body, { status: 201 })
|
||||
}),
|
||||
|
||||
http.put('/api/admin/classes/:id', async ({ request }) => {
|
||||
const body = await request.json()
|
||||
return jsonResponse(body)
|
||||
}),
|
||||
|
||||
http.delete('/api/admin/classes/:id', () => noContent()),
|
||||
|
||||
http.get('/api/admin/settings', () =>
|
||||
jsonResponse({
|
||||
id: 'sys-settings-1',
|
||||
name: 'Test System',
|
||||
militaryUnit: null,
|
||||
defaultCameraWidth: 1920,
|
||||
defaultCameraFoV: 60,
|
||||
thumbnailWidth: 256,
|
||||
thumbnailHeight: 256,
|
||||
thumbnailBorder: 2,
|
||||
generateAnnotatedImage: true,
|
||||
silentDetection: false,
|
||||
}),
|
||||
),
|
||||
|
||||
http.put('/api/admin/settings', async ({ request }) => {
|
||||
const body = await request.json()
|
||||
return jsonResponse(body)
|
||||
}),
|
||||
|
||||
// Test-only suite endpoint — gated behind a non-production build flag in the
|
||||
// real `admin/` service. The fast-profile MSW just returns 204 so isolation
|
||||
// helpers can call it uniformly with the e2e profile.
|
||||
http.post('/api/admin/test-only/reset', () => noContent()),
|
||||
]
|
||||
@@ -0,0 +1,90 @@
|
||||
import { http } from 'msw'
|
||||
import { jsonResponse, noContent, paginate, sse } from '../helpers'
|
||||
import { seedMedia } from '../../fixtures/seed_media'
|
||||
import { seedAnnotations } from '../../fixtures/seed_annotations'
|
||||
import { seedUserSettings } from '../../fixtures/seed_user_settings'
|
||||
|
||||
// Default `/api/annotations/*` handlers — media list, annotation CRUD, dataset,
|
||||
// status SSE. The annotation status SSE returns a small canned event sequence
|
||||
// so dataset / annotations tests don't have to register their own stream just
|
||||
// to mount a component.
|
||||
|
||||
export const annotationsHandlers = [
|
||||
http.get('/api/annotations/media', ({ request }) => {
|
||||
const url = new URL(request.url)
|
||||
const page = Number(url.searchParams.get('page') ?? '1')
|
||||
const pageSize = Number(url.searchParams.get('pageSize') ?? String(seedMedia.length))
|
||||
return jsonResponse(paginate(seedMedia, page, pageSize))
|
||||
}),
|
||||
|
||||
http.get('/api/annotations/media/:id', ({ params }) => {
|
||||
const m = seedMedia.find((x) => x.id === params.id)
|
||||
if (!m) return new Response(null, { status: 404 })
|
||||
return jsonResponse(m)
|
||||
}),
|
||||
|
||||
http.get('/api/annotations/media/:id/annotations', ({ params }) =>
|
||||
jsonResponse(seedAnnotations.filter((a) => a.mediaId === params.id)),
|
||||
),
|
||||
|
||||
http.get('/api/annotations', () => jsonResponse(seedAnnotations)),
|
||||
|
||||
http.post('/api/annotations', async ({ request }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>
|
||||
return jsonResponse({ id: 'ann-new', createdDate: new Date().toISOString(), ...body }, { status: 201 })
|
||||
}),
|
||||
|
||||
http.patch('/api/annotations/:id/status', async ({ request, params }) => {
|
||||
const body = (await request.json()) as { status?: number }
|
||||
return jsonResponse({ id: params.id, status: body.status ?? 10 })
|
||||
}),
|
||||
|
||||
http.delete('/api/annotations/:id', () => noContent()),
|
||||
|
||||
http.get('/api/annotations/dataset', () =>
|
||||
jsonResponse(
|
||||
seedAnnotations.map((a) => ({
|
||||
annotationId: a.id,
|
||||
imageName: `image-${a.mediaId}.jpg`,
|
||||
thumbnailPath: `/thumbs/${a.mediaId}.jpg`,
|
||||
status: a.status,
|
||||
createdDate: a.createdDate,
|
||||
createdEmail: 'op_alice@test.local',
|
||||
flightName: 'Flight 1',
|
||||
source: a.source,
|
||||
isSeed: false,
|
||||
isSplit: a.isSplit,
|
||||
})),
|
||||
),
|
||||
),
|
||||
|
||||
http.post('/api/annotations/dataset/bulk-status', async ({ request }) => {
|
||||
const body = (await request.json()) as { ids?: string[]; status?: number }
|
||||
return jsonResponse({ updated: body.ids?.length ?? 0, status: body.status ?? 30 })
|
||||
}),
|
||||
|
||||
http.get('/api/annotations/dataset/distribution', () =>
|
||||
jsonResponse([
|
||||
{ classNum: 0, label: 'class-0', color: '#ff0000', count: 12 },
|
||||
{ classNum: 1, label: 'class-1', color: '#00ff00', count: 7 },
|
||||
]),
|
||||
),
|
||||
|
||||
http.get('/api/annotations/status', () =>
|
||||
sse([
|
||||
{ event: 'status', data: { annotationId: seedAnnotations[0]?.id ?? 'ann-1', status: 20 }, id: '1' },
|
||||
{ event: 'status', data: { annotationId: seedAnnotations[0]?.id ?? 'ann-1', status: 30 }, id: '2' },
|
||||
]),
|
||||
),
|
||||
|
||||
http.get('/api/annotations/users/:userId/settings', ({ params }) => {
|
||||
const s = seedUserSettings.find((x) => x.userId === params.userId)
|
||||
if (!s) return new Response(null, { status: 404 })
|
||||
return jsonResponse(s)
|
||||
}),
|
||||
|
||||
http.put('/api/annotations/users/:userId/settings', async ({ request, params }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>
|
||||
return jsonResponse({ id: 'user-settings-1', userId: params.userId, ...body })
|
||||
}),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
import { http } from 'msw'
|
||||
import { jsonResponse } from '../helpers'
|
||||
|
||||
// Default `/api/detect/*` handlers — sync image detect.
|
||||
|
||||
export const detectHandlers = [
|
||||
http.post('/api/detect/image', () =>
|
||||
jsonResponse({
|
||||
detections: [
|
||||
{
|
||||
id: 'det-1',
|
||||
classNum: 0,
|
||||
label: 'class-0',
|
||||
confidence: 0.92,
|
||||
affiliation: 20,
|
||||
combatReadiness: 1,
|
||||
centerX: 0.5,
|
||||
centerY: 0.5,
|
||||
width: 0.1,
|
||||
height: 0.1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,63 @@
|
||||
import { http } from 'msw'
|
||||
import { jsonResponse, noContent, sse } from '../helpers'
|
||||
import { seedFlights } from '../../fixtures/seed_flights'
|
||||
import { seedAircraft } from '../../fixtures/seed_aircraft'
|
||||
|
||||
// Default `/api/flights/*` handlers. Live-GPS SSE returns a deterministic
|
||||
// 3-event stream so AC-08 timing assertions have something to drive even
|
||||
// without per-test overrides.
|
||||
|
||||
export const flightsHandlers = [
|
||||
http.get('/api/flights', () => jsonResponse(seedFlights)),
|
||||
|
||||
http.get('/api/flights/:id', ({ params }) => {
|
||||
const flight = seedFlights.find((f) => f.id === params.id)
|
||||
if (!flight) return new Response(null, { status: 404 })
|
||||
return jsonResponse(flight)
|
||||
}),
|
||||
|
||||
http.post('/api/flights', async ({ request }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>
|
||||
return jsonResponse({ id: 'flight-new', createdDate: new Date().toISOString(), ...body }, { status: 201 })
|
||||
}),
|
||||
|
||||
http.put('/api/flights/:id', async ({ request, params }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>
|
||||
return jsonResponse({ id: params.id, ...body })
|
||||
}),
|
||||
|
||||
http.delete('/api/flights/:id', () => noContent()),
|
||||
|
||||
http.get('/api/flights/:id/waypoints', ({ params }) =>
|
||||
jsonResponse([
|
||||
{
|
||||
id: 'wp-1',
|
||||
flightId: params.id,
|
||||
name: 'WP1',
|
||||
latitude: 50.45,
|
||||
longitude: 30.52,
|
||||
order: 1,
|
||||
},
|
||||
]),
|
||||
),
|
||||
|
||||
http.post('/api/flights/:id/waypoints', async ({ request, params }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>
|
||||
return jsonResponse({ id: 'wp-new', flightId: params.id, ...body }, { status: 201 })
|
||||
}),
|
||||
|
||||
http.get('/api/flights/:id/live-gps', ({ params }) =>
|
||||
sse([
|
||||
{ event: 'gps', data: { flightId: params.id, lat: 50.45, lon: 30.52, t: 0 }, id: '1' },
|
||||
{ event: 'gps', data: { flightId: params.id, lat: 50.46, lon: 30.53, t: 1000 }, id: '2' },
|
||||
{ event: 'gps', data: { flightId: params.id, lat: 50.47, lon: 30.54, t: 2000 }, id: '3' },
|
||||
]),
|
||||
),
|
||||
|
||||
http.get('/api/flights/aircraft', () => jsonResponse(seedAircraft)),
|
||||
|
||||
http.post('/api/flights/aircraft', async ({ request }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>
|
||||
return jsonResponse({ id: 'aircraft-new', ...body }, { status: 201 })
|
||||
}),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
import { adminHandlers } from './admin'
|
||||
import { flightsHandlers } from './flights'
|
||||
import { annotationsHandlers } from './annotations'
|
||||
import { detectHandlers } from './detect'
|
||||
import { loaderHandlers } from './loader'
|
||||
import { resourceHandlers } from './resource'
|
||||
import { owmHandlers } from './owm'
|
||||
import { tilesHandlers } from './tiles'
|
||||
|
||||
// Default-handler registration order is irrelevant (MSW matches by request shape),
|
||||
// but grouping the exports here gives test files a single import surface for
|
||||
// the seeded baseline. Per-test overrides land via `server.use(...)`.
|
||||
export const defaultHandlers = [
|
||||
...adminHandlers,
|
||||
...flightsHandlers,
|
||||
...annotationsHandlers,
|
||||
...detectHandlers,
|
||||
...loaderHandlers,
|
||||
...resourceHandlers,
|
||||
...owmHandlers,
|
||||
...tilesHandlers,
|
||||
]
|
||||
|
||||
export {
|
||||
adminHandlers,
|
||||
flightsHandlers,
|
||||
annotationsHandlers,
|
||||
detectHandlers,
|
||||
loaderHandlers,
|
||||
resourceHandlers,
|
||||
owmHandlers,
|
||||
tilesHandlers,
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { http } from 'msw'
|
||||
import { jsonResponse, noContent } from '../helpers'
|
||||
|
||||
// Default `/api/loader/*` handlers. The loader service brokers media uploads;
|
||||
// AC-10 (≤ 500 MB cap) is asserted in tests by overriding the POST handler
|
||||
// with a 413 stub.
|
||||
|
||||
export const loaderHandlers = [
|
||||
http.post('/api/loader/upload', () => jsonResponse({ id: 'media-uploaded-1', status: 1 }, { status: 201 })),
|
||||
|
||||
http.get('/api/loader/jobs/:id', ({ params }) =>
|
||||
jsonResponse({ id: params.id, status: 'completed', progress: 1.0 }),
|
||||
),
|
||||
|
||||
http.delete('/api/loader/jobs/:id', () => noContent()),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
import { http } from 'msw'
|
||||
import { jsonResponse } from '../helpers'
|
||||
|
||||
// OpenWeatherMap stand-in for the fast profile. The e2e profile uses
|
||||
// `e2e/stubs/owm/` (Docker). Both must return the same shape so tests
|
||||
// targeting the wind-compute path (E10) behave identically.
|
||||
|
||||
export const owmHandlers = [
|
||||
// The production code is expected to call OWM through a configurable base URL
|
||||
// (default = api.openweathermap.org); the route-abort guard in the e2e
|
||||
// profile blocks that host, but tests under fast hit the path-only form so
|
||||
// MSW intercepts.
|
||||
http.get('https://api.openweathermap.org/data/2.5/weather', () =>
|
||||
jsonResponse({ wind: { speed: 5.0, deg: 270 }, name: 'TestCity' }),
|
||||
),
|
||||
|
||||
http.get('http://owm-stub:8081/data/2.5/weather', () =>
|
||||
jsonResponse({ wind: { speed: 5.0, deg: 270 }, name: 'TestCity' }),
|
||||
),
|
||||
|
||||
http.get('/owm/data/2.5/weather', () =>
|
||||
jsonResponse({ wind: { speed: 5.0, deg: 270 }, name: 'TestCity' }),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { jsonResponse } from '../helpers'
|
||||
|
||||
// Default `/api/resource/*` handlers — image / thumbnail / video binary serving.
|
||||
// Returns a tiny PNG stub for any image request so layout tests can mount
|
||||
// without 404 noise.
|
||||
const ONE_PX_PNG = Uint8Array.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
||||
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x62, 0x00, 0x01, 0x00, 0x00,
|
||||
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
|
||||
0x42, 0x60, 0x82,
|
||||
])
|
||||
|
||||
export const resourceHandlers = [
|
||||
http.get('/api/resource/images/:name', () =>
|
||||
new HttpResponse(ONE_PX_PNG, { headers: { 'Content-Type': 'image/png' } }),
|
||||
),
|
||||
|
||||
http.get('/api/resource/thumbnails/:name', () =>
|
||||
new HttpResponse(ONE_PX_PNG, { headers: { 'Content-Type': 'image/png' } }),
|
||||
),
|
||||
|
||||
http.get('/api/resource/videos/:name', () =>
|
||||
jsonResponse({ url: '/api/resource/videos/stub.mp4' }),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
import { http, HttpResponse } from 'msw'
|
||||
|
||||
// OSM/Esri tile stand-in for the fast profile. Returns a tiny transparent
|
||||
// PNG so `<img>` / Leaflet tile loads succeed in jsdom without exiting the
|
||||
// process.
|
||||
const TILE_PNG = Uint8Array.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
||||
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x62, 0x00, 0x01, 0x00, 0x00,
|
||||
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
|
||||
0x42, 0x60, 0x82,
|
||||
])
|
||||
|
||||
const tile = () => new HttpResponse(TILE_PNG, { headers: { 'Content-Type': 'image/png' } })
|
||||
|
||||
export const tilesHandlers = [
|
||||
// OSM XYZ scheme: {z}/{x}/{y}
|
||||
http.get('https://*.tile.openstreetmap.org/:z/:x/:y.png', tile),
|
||||
http.get('https://tile.openstreetmap.org/:z/:x/:y.png', tile),
|
||||
// Esri ArcGIS satellite scheme: {z}/{y}/{x}
|
||||
http.get('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/:z/:y/:x', tile),
|
||||
// Local tile-stub aliases (e2e parity)
|
||||
http.get('http://tile-stub:8082/:z/:x/:y.png', tile),
|
||||
http.get('http://tile-stub:8082/sat/:z/:y/:x', tile),
|
||||
http.get('/tiles/:z/:x/:y.png', tile),
|
||||
]
|
||||
@@ -0,0 +1,57 @@
|
||||
import { HttpResponse, delay } from 'msw'
|
||||
|
||||
/** Small, opinionated wrappers over MSW's response helpers used across handlers + tests. */
|
||||
|
||||
export function jsonResponse<T>(body: T, init?: ResponseInit) {
|
||||
return HttpResponse.json(body as object, init)
|
||||
}
|
||||
|
||||
export function errorResponse(status: number, message: string) {
|
||||
return HttpResponse.json({ error: message, status }, { status })
|
||||
}
|
||||
|
||||
export function noContent() {
|
||||
return new HttpResponse(null, { status: 204 })
|
||||
}
|
||||
|
||||
export function paginate<T>(items: T[], page = 1, pageSize = items.length) {
|
||||
const start = (page - 1) * pageSize
|
||||
const slice = items.slice(start, start + pageSize)
|
||||
return { items: slice, totalCount: items.length, page, pageSize }
|
||||
}
|
||||
|
||||
/** Inject latency into a handler. Use as: `await latency(50)` inside the resolver. */
|
||||
export function latency(ms: number) {
|
||||
return delay(ms)
|
||||
}
|
||||
|
||||
/** Build a Server-Sent-Events (SSE) `text/event-stream` body from a sequence of payloads. */
|
||||
export function sse(events: Array<{ event?: string; data: unknown; id?: string }>) {
|
||||
const encoder = new TextEncoder()
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
for (const e of events) {
|
||||
const lines: string[] = []
|
||||
if (e.id !== undefined) lines.push(`id: ${e.id}`)
|
||||
if (e.event !== undefined) lines.push(`event: ${e.event}`)
|
||||
const payload = typeof e.data === 'string' ? e.data : JSON.stringify(e.data)
|
||||
for (const line of payload.split('\n')) lines.push(`data: ${line}`)
|
||||
lines.push('', '')
|
||||
controller.enqueue(encoder.encode(lines.join('\n')))
|
||||
}
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
return new HttpResponse(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Drop the connection mid-flight (used by resilience tests). */
|
||||
export function dropResponse() {
|
||||
return HttpResponse.error()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { setupServer } from 'msw/node'
|
||||
import { defaultHandlers } from './handlers'
|
||||
|
||||
// Node-side MSW server shared by every fast-profile test. The Service-Worker
|
||||
// runtime (`msw/browser`) is intentionally NOT imported anywhere under tests/;
|
||||
// see AZ-456 Risk 1 — mixing the two silently bypasses MSW.
|
||||
export const server = setupServer(...defaultHandlers)
|
||||
Reference in New Issue
Block a user