Skip to content

Commit

Permalink
feat: support concurrent test runs via "server.boundary" (#2000)
Browse files Browse the repository at this point in the history
  • Loading branch information
kettanaito committed Feb 12, 2024
1 parent f805d34 commit 450e7bc
Show file tree
Hide file tree
Showing 15 changed files with 439 additions and 139 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
"check:exports": "node \"./config/scripts/validate-esm.js\"",
"test": "pnpm test:unit && pnpm test:node && pnpm test:browser && pnpm test:native",
"test:unit": "vitest",
"test:node": "vitest --config=./test/node/vitest.config.ts",
"test:node": "vitest run --config=./test/node/vitest.config.ts",
"test:native": "vitest --config=./test/native/vitest.config.ts",
"test:browser": "playwright test -c ./test/browser/playwright.config.ts",
"test:modules:node": "vitest --config=./test/modules/node/vitest.config.ts",
Expand Down
2 changes: 1 addition & 1 deletion src/browser/setupWorker/glossary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export interface SetupWorkerInternalContext {
startOptions: RequiredDeep<StartOptions>
worker: ServiceWorker | null
registration: ServiceWorkerRegistration | null
requestHandlers: Array<RequestHandler>
getRequestHandlers(): Array<RequestHandler>
requests: Map<string, Request>
emitter: Emitter<LifeCycleEventsMap>
keepAliveInterval?: number
Expand Down
14 changes: 3 additions & 11 deletions src/browser/setupWorker/setupWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,10 @@ export class SetupWorkerApi
isMockingEnabled: false,
startOptions: null as any,
worker: null,
getRequestHandlers: () => {
return this.handlersController.currentHandlers()
},
registration: null,
requestHandlers: this.currentHandlers,
requests: new Map(),
emitter: this.emitter,
workerChannel: {
Expand Down Expand Up @@ -151,16 +153,6 @@ export class SetupWorkerApi
},
}

/**
* @todo Not sure I like this but "this.currentHandlers"
* updates never bubble to "this.context.requestHandlers".
*/
Object.defineProperties(context, {
requestHandlers: {
get: () => this.currentHandlers,
},
})

this.startHandler = context.supports.serviceWorkerApi
? createFallbackStart(context)
: createStartHandler(context)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export function createFallbackRequestListener(
const response = await handleRequest(
request,
requestId,
context.requestHandlers,
context.getRequestHandlers(),
options,
context.emitter,
{
Expand Down
2 changes: 1 addition & 1 deletion src/browser/setupWorker/start/createRequestListener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export const createRequestListener = (
await handleRequest(
request,
requestId,
context.requestHandlers,
context.getRequestHandlers(),
options,
context.emitter,
{
Expand Down
42 changes: 33 additions & 9 deletions src/core/SetupApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,38 @@ import { pipeEvents } from './utils/internal/pipeEvents'
import { toReadonlyArray } from './utils/internal/toReadonlyArray'
import { Disposable } from './utils/internal/Disposable'

export abstract class HandlersController {
abstract prepend(runtimeHandlers: Array<RequestHandler>): void
abstract reset(nextHandles: Array<RequestHandler>): void
abstract currentHandlers(): Array<RequestHandler>
}

export class InMemoryHandlersController implements HandlersController {
private handlers: Array<RequestHandler>

constructor(private initialHandlers: Array<RequestHandler>) {
this.handlers = [...initialHandlers]
}

public prepend(runtimeHandles: Array<RequestHandler>): void {
this.handlers.unshift(...runtimeHandles)
}

public reset(nextHandlers: Array<RequestHandler>): void {
this.handlers =
nextHandlers.length > 0 ? [...nextHandlers] : [...this.initialHandlers]
}

public currentHandlers(): Array<RequestHandler> {
return this.handlers
}
}

/**
* Generic class for the mock API setup.
*/
export abstract class SetupApi<EventsMap extends EventMap> extends Disposable {
protected initialHandlers: ReadonlyArray<RequestHandler>
protected currentHandlers: Array<RequestHandler>
protected handlersController: HandlersController
protected readonly emitter: Emitter<EventsMap>
protected readonly publicEmitter: Emitter<EventsMap>

Expand All @@ -31,8 +57,7 @@ export abstract class SetupApi<EventsMap extends EventMap> extends Disposable {
),
)

this.initialHandlers = toReadonlyArray(initialHandlers)
this.currentHandlers = [...initialHandlers]
this.handlersController = new InMemoryHandlersController(initialHandlers)

this.emitter = new Emitter<EventsMap>()
this.publicEmitter = new Emitter<EventsMap>()
Expand All @@ -59,24 +84,23 @@ export abstract class SetupApi<EventsMap extends EventMap> extends Disposable {
),
)

this.currentHandlers.unshift(...runtimeHandlers)
this.handlersController.prepend(runtimeHandlers)
}

public restoreHandlers(): void {
this.currentHandlers.forEach((handler) => {
this.handlersController.currentHandlers().forEach((handler) => {
handler.isUsed = false
})
}

public resetHandlers(...nextHandlers: Array<RequestHandler>): void {
this.currentHandlers =
nextHandlers.length > 0 ? [...nextHandlers] : [...this.initialHandlers]
this.handlersController.reset(nextHandlers)
}

public listHandlers(): ReadonlyArray<
RequestHandler<RequestHandlerDefaultInfo, any, any>
> {
return toReadonlyArray(this.currentHandlers)
return toReadonlyArray(this.handlersController.currentHandlers())
}

private createLifeCycleEvents(): LifeCycleEventEmitter<EventsMap> {
Expand Down
10 changes: 5 additions & 5 deletions src/native/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { FetchInterceptor } from '@mswjs/interceptors/fetch'
import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest'
import { RequestHandler } from '~/core/handlers/RequestHandler'
import { SetupServerApi } from '../node/SetupServerApi'
import type { RequestHandler } from '~/core/handlers/RequestHandler'
import { SetupServerCommonApi } from '../node/SetupServerCommonApi'

/**
* Sets up a requests interception in React Native with the given request handlers.
Expand All @@ -11,11 +11,11 @@ import { SetupServerApi } from '../node/SetupServerApi'
*/
export function setupServer(
...handlers: Array<RequestHandler>
): SetupServerApi {
): SetupServerCommonApi {
// Provision request interception via patching the `XMLHttpRequest` class only
// in React Native. There is no `http`/`https` modules in that environment.
return new SetupServerApi(
return new SetupServerCommonApi(
[FetchInterceptor, XMLHttpRequestInterceptor],
...handlers,
handlers,
)
}
159 changes: 64 additions & 95 deletions src/node/SetupServerApi.ts
Original file line number Diff line number Diff line change
@@ -1,113 +1,82 @@
import {
BatchInterceptor,
HttpRequestEventMap,
Interceptor,
InterceptorReadyState,
} from '@mswjs/interceptors'
import { invariant } from 'outvariant'
import { SetupApi } from '~/core/SetupApi'
import { RequestHandler } from '~/core/handlers/RequestHandler'
import { LifeCycleEventsMap, SharedOptions } from '~/core/sharedOptions'
import { RequiredDeep } from '~/core/typeUtils'
import { handleRequest } from '~/core/utils/handleRequest'
import { devUtils } from '~/core/utils/internal/devUtils'
import { mergeRight } from '~/core/utils/internal/mergeRight'
import { SetupServer } from './glossary'
import { AsyncLocalStorage } from 'node:async_hooks'
import { ClientRequestInterceptor } from '@mswjs/interceptors/ClientRequest'
import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest'
import { FetchInterceptor } from '@mswjs/interceptors/fetch'
import { HandlersController } from '~/core/SetupApi'
import type { RequestHandler } from '~/core/handlers/RequestHandler'
import type { SetupServer } from './glossary'
import { SetupServerCommonApi } from './SetupServerCommonApi'

const DEFAULT_LISTEN_OPTIONS: RequiredDeep<SharedOptions> = {
onUnhandledRequest: 'warn',
}

export class SetupServerApi
extends SetupApi<LifeCycleEventsMap>
implements SetupServer
{
protected readonly interceptor: BatchInterceptor<
Array<Interceptor<HttpRequestEventMap>>,
HttpRequestEventMap
>
private resolvedOptions: RequiredDeep<SharedOptions>
const store = new AsyncLocalStorage<RequestHandlersContext>()

constructor(
interceptors: Array<{
new (): Interceptor<HttpRequestEventMap>
}>,
...handlers: Array<RequestHandler>
) {
super(...handlers)
type RequestHandlersContext = {
initialHandlers: Array<RequestHandler>
handlers: Array<RequestHandler>
}

this.interceptor = new BatchInterceptor({
name: 'setup-server',
interceptors: interceptors.map((Interceptor) => new Interceptor()),
})
this.resolvedOptions = {} as RequiredDeep<SharedOptions>
/**
* A handlers controller that utilizes `AsyncLocalStorage` in Node.js
* to prevent the request handlers list from being a shared state
* across mutliple tests.
*/
class AsyncHandlersController implements HandlersController {
private rootContext: RequestHandlersContext

this.init()
constructor(initialHandlers: Array<RequestHandler>) {
this.rootContext = { initialHandlers, handlers: [] }
}

/**
* Subscribe to all requests that are using the interceptor object
*/
private init(): void {
this.interceptor.on('request', async ({ request, requestId }) => {
const response = await handleRequest(
request,
requestId,
this.currentHandlers,
this.resolvedOptions,
this.emitter,
)

if (response) {
request.respondWith(response)
}
get context(): RequestHandlersContext {
return store.getStore() || this.rootContext
}

return
})
public prepend(runtimeHandlers: Array<RequestHandler>) {
this.context.handlers.unshift(...runtimeHandlers)
}

this.interceptor.on(
'response',
({ response, isMockedResponse, request, requestId }) => {
this.emitter.emit(
isMockedResponse ? 'response:mocked' : 'response:bypass',
{
response,
request,
requestId,
},
)
},
)
public reset(nextHandlers: Array<RequestHandler>) {
const context = this.context
context.handlers = []
context.initialHandlers =
nextHandlers.length > 0 ? nextHandlers : context.initialHandlers
}

public listen(options: Partial<SharedOptions> = {}): void {
this.resolvedOptions = mergeRight(
DEFAULT_LISTEN_OPTIONS,
options,
) as RequiredDeep<SharedOptions>
public currentHandlers(): Array<RequestHandler> {
const { initialHandlers, handlers } = this.context
return handlers.concat(initialHandlers)
}
}

// Apply the interceptor when starting the server.
this.interceptor.apply()
export class SetupServerApi
extends SetupServerCommonApi
implements SetupServer
{
constructor(handlers: Array<RequestHandler>) {
super(
[ClientRequestInterceptor, XMLHttpRequestInterceptor, FetchInterceptor],
handlers,
)

this.subscriptions.push(() => {
this.interceptor.dispose()
})
this.handlersController = new AsyncHandlersController(handlers)
}

// Assert that the interceptor has been applied successfully.
// Also guards us from forgetting to call "interceptor.apply()"
// as a part of the "listen" method.
invariant(
[InterceptorReadyState.APPLYING, InterceptorReadyState.APPLIED].includes(
this.interceptor.readyState,
),
devUtils.formatMessage(
'Failed to start "setupServer": the interceptor failed to apply. This is likely an issue with the library and you should report it at "%s".',
),
'https://github.com/mswjs/msw/issues/new/choose',
)
public boundary<Fn extends (...args: Array<any>) => unknown>(
callback: Fn,
): (...args: Parameters<Fn>) => ReturnType<Fn> {
return (...args: Parameters<Fn>): ReturnType<Fn> => {
return store.run<any, any>(
{
initialHandlers: this.handlersController.currentHandlers(),
handlers: [],
},
callback,
...args,
)
}
}

public close(): void {
this.dispose()
super.close()
store.disable()
}
}
Loading

0 comments on commit 450e7bc

Please sign in to comment.