import { Spy } from '#test/medium/mock/helpers.ts'; import { getOperation, hasOperation, matchOperation, toCall } from '#test/medium/mock/spec.ts'; import type { Call, Handler, HandlerContext, Handlers, OperationId } from '#test/medium/mock/types.ts'; import type { BrowserContext, Request, Route, WebSocketRoute } from '@playwright/test'; const apiPattern = /^https?:\/\/[^/]+\/api\//; const socketPattern = /\/api\/socket\.io\//; const customCssPattern = /^https?:\/\/[^/]+\/custom\.css$/; const mapStylePattern = /^https:\/\/tiles\.immich\.cloud\//; const emptyMapStyle = { version: 8, sources: {}, layers: [] }; const sockets = new WeakMap(); const socketMockedContexts = new WeakSet(); const mockWebSocket = async (context: BrowserContext) => { if (socketMockedContexts.has(context)) { return; } socketMockedContexts.add(context); await context.routeWebSocket(socketPattern, (socket) => { sockets.set(context, socket); socket.send('0{"sid":"mock","upgrades":[],"pingInterval":25000,"pingTimeout":20000,"maxPayload":1000000}'); socket.onMessage((message) => { if (message === '2') { socket.send('3'); } else if (typeof message === 'string' && message.startsWith('40')) { socket.send('40{"sid":"mock"}'); } }); }); }; const fulfill = async (route: Route, options: Parameters[0]) => { try { await route.fulfill(options); } catch { return; } }; const parseBody = (request: Request) => { const data = request.postData(); if (data === null) { return; } try { return JSON.parse(data) as unknown; } catch { return data; } }; const mutationMethods = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); const readOperations = new Set(['searchAssets', 'getDownloadInfo', 'downloadArchive']); export class OperationSpy extends Spy> { constructor( readonly operationId: K, readonly method: string, ) { super(`api.${operationId}`); } get isMutation() { return mutationMethods.has(this.method) && !readOperations.has(this.operationId); } } type OperationSpies = { readonly [K in OperationId]: OperationSpy }; export type MockApi = MockApiBase & OperationSpies; export const createMockApi = (context: BrowserContext): MockApi => { const api = new MockApiBase(context); return new Proxy(api, { get: (target, property, receiver) => { if (Reflect.has(target, property)) { return Reflect.get(target, property, receiver); } return typeof property === 'string' && hasOperation(property) ? target.spy(property as OperationId) : undefined; }, }) as MockApi; }; export class MockApiBase { readonly unhandled: string[] = []; readonly failures: string[] = []; private readonly routedUrls = new Set(); private readonly handlers = new Map>(); private readonly spies = new Map>(); private readonly disposers: Array<() => Promise> = []; constructor(private readonly context: BrowserContext) {} private async route(pattern: RegExp, handler: (route: Route, request: Request) => Promise) { await this.context.route(pattern, handler); this.disposers.push(() => this.context.unroute(pattern)); } spy(operationId: OperationId) { let spy = this.spies.get(operationId); if (!spy) { spy = new OperationSpy(operationId, getOperation(operationId).method); this.spies.set(operationId, spy); } return spy; } async install() { await mockWebSocket(this.context); await this.route(customCssPattern, (route) => route.fulfill({ body: '', contentType: 'text/css' })); await this.route(mapStylePattern, (route) => route.fulfill({ json: emptyMapStyle })); await this.route(apiPattern, (route, request) => this.handle(route, request)); } async uninstall() { for (const dispose of this.disposers.splice(0).reverse()) { await dispose(); } } on(handlers: Handlers) { for (const [operationId, handler] of Object.entries(handlers)) { this.assertOperation(operationId); this.handlers.set(operationId, handler as Handler); } return this; } off(operationId: OperationId) { this.handlers.delete(operationId); return this; } emit(event: string, payload?: unknown) { const socket = sockets.get(this.context); if (!socket) { throw new Error('No websocket connection has been established by the page'); } socket.send(`42${JSON.stringify(payload === undefined ? [event] : [event, payload])}`); } recordDownload(url: URL) { if (this.routedUrls.has(url.href)) { return; } const match = matchOperation('GET', url.pathname); if (!match) { this.unhandled.push(`GET ${url.pathname}${url.search} (download without a matching OpenAPI operation)`); return; } const call = toCall(match.operation, match.params, url.searchParams, undefined); this.spy(match.operation.operationId as OperationId).record(call as Call, { expected: true }); } unexpectedCalls() { return [...this.spies.values()].flatMap((spy) => spy.unexpected().map((call) => `${spy.method} ${spy.operationId} ${JSON.stringify(call)}`), ); } private assertOperation(operationId: string) { if (!hasOperation(operationId)) { throw new Error(`Unknown operationId: ${operationId}`); } } private async handle(route: Route, request: Request) { const url = new URL(request.url()); const method = request.method(); this.routedUrls.add(url.href); const match = matchOperation(method, url.pathname); if (!match) { this.unhandled.push(`${method} ${url.pathname}${url.search} (no matching OpenAPI operation)`); return fulfill(route, { status: 501, json: { message: 'No matching OpenAPI operation' } }); } const { operation, params } = match; const body = parseBody(request); const call = toCall(operation, params, url.searchParams, body); const spy = this.spy(operation.operationId as OperationId); spy.record(call as Call, { expected: spy.isMutation }); const handler = this.handlers.get(operation.operationId); if (!handler) { this.unhandled.push(`${method} ${url.pathname}${url.search} (${operation.operationId})`); return fulfill(route, { status: 501, json: { message: `No mock handler for ${operation.operationId}` } }); } let result; try { result = await handler({ ...call, request } as HandlerContext); } catch (error) { const message = error instanceof Error ? (error.stack ?? error.message) : String(error); this.failures.push(`${operation.operationId}: ${message}`); return fulfill(route, { status: 500, json: { message: `Mock handler for ${operation.operationId} threw` } }); } if (result && 'body' in result && result.body !== undefined) { return fulfill(route, { status: result.status ?? 200, body: result.body, contentType: result.contentType, headers: result.headers, }); } if (result && 'json' in result && result.json !== undefined) { return fulfill(route, { status: result.status ?? 200, json: result.json, headers: result.headers }); } return fulfill(route, { status: result?.status ?? 204, headers: result?.headers }); } }