diff --git a/packages/browser/src/client/client.ts b/packages/browser/src/client/client.ts index c848ca02f..a143a371a 100644 --- a/packages/browser/src/client/client.ts +++ b/packages/browser/src/client/client.ts @@ -6,6 +6,7 @@ import type { WebSocketBrowserEvents, WebSocketBrowserHandlers } from '../types' import type { IframeOrchestrator } from './orchestrator' import { createBirpc } from 'birpc' import { parse, stringify } from 'flatted' +import { replaceUndefined, reviveUndefined } from '../shared/serialization' import { getBrowserState } from './utils' const PAGE_TYPE = getBrowserState().type @@ -134,9 +135,9 @@ function createClient() { stack: v.stack, } } - return v + return replaceUndefined(v) }), - deserialize: parse, + deserialize: (e) => parse(e, reviveUndefined), timeout: -1, // createTesters can take a while }, ) diff --git a/packages/browser/src/client/tester/tester-utils.ts b/packages/browser/src/client/tester/tester-utils.ts index 61ec06afd..d207242b3 100644 --- a/packages/browser/src/client/tester/tester-utils.ts +++ b/packages/browser/src/client/tester/tester-utils.ts @@ -141,7 +141,6 @@ export class CommandsManager { const rpc = state.rpc as any as BrowserRPC const { sessionId, traces } = getBrowserState() const filepath = state.filepath || state.current?.file?.filepath - args = args.filter((arg) => arg !== undefined) // remove optional fields const actionTraceGroupName = ACTION_TRACE_COMMANDS.has(command) ? `vitest:${command.slice('__vitest_'.length)}` @@ -179,7 +178,10 @@ export class CommandsManager { let status: BrowserTraceEntryStatus = 'pass' const traceRangeId = hasActiveTraceView ? createBrowserTraceRangeId() : undefined const element = - typeof args[0] === 'object' && 'selector' in args[0] && 'locator' in args[0] + typeof args[0] === 'object' && + args[0] !== null && + 'selector' in args[0] && + 'locator' in args[0] ? args[0] : undefined if (hasActiveTraceView) { diff --git a/packages/browser/src/node/rpc.ts b/packages/browser/src/node/rpc.ts index a252a9819..7139d0da8 100644 --- a/packages/browser/src/node/rpc.ts +++ b/packages/browser/src/node/rpc.ts @@ -32,6 +32,7 @@ import { isValidApiRequest, } from 'vitest/node' import { WebSocketServer } from 'ws' +import { replaceUndefined, reviveUndefined } from '../shared/serialization' import { slash } from './utils' const debug = createDebugger('vitest:browser:api') @@ -538,7 +539,7 @@ export function setupBrowserRpc( on: (fn) => ws.on('message', fn), eventNames: ['onCancel', 'cdpEvent'], serialize: (data: any) => stringify(data, stringifyReplace), - deserialize: parse, + deserialize: (data) => parse(data, reviveUndefined), timeout: -1, // createTesters can take a long time }, ) @@ -574,6 +575,6 @@ function stringifyReplace(key: string, value: any): any { ...cloned, } } else { - return value + return replaceUndefined(value) } } diff --git a/packages/browser/src/shared/serialization.ts b/packages/browser/src/shared/serialization.ts new file mode 100644 index 000000000..4046c846a --- /dev/null +++ b/packages/browser/src/shared/serialization.ts @@ -0,0 +1,10 @@ +// JSON drops `undefined` in objects and turns it into `null` in arrays +const UNDEFINED_VALUE = '__vitest_undefined__' + +export function replaceUndefined(value: unknown): unknown { + return value === undefined ? UNDEFINED_VALUE : value +} + +export function reviveUndefined(_key: string, value: unknown): unknown { + return value === UNDEFINED_VALUE ? undefined : value +} diff --git a/test/browser/test/commands.test.ts b/test/browser/test/commands.test.ts index dd323e12f..3484f1f10 100644 --- a/test/browser/test/commands.test.ts +++ b/test/browser/test/commands.test.ts @@ -41,22 +41,47 @@ it('can manipulate files', async () => { }) it('can run custom commands', async () => { - const result = await myCustomCommand('arg1', 'arg2') - expect(result).toEqual({ - testPath: expect.stringMatching('test/browser/test/commands.test.ts'), - arg1: 'arg1', - arg2: 'arg2', - }) + { + const result = await myCustomCommand('arg1', 'arg2') + expect(result).toEqual({ + testPath: expect.stringMatching('test/browser/test/commands.test.ts'), + arg1: 'arg1', + arg2: 'arg2', + }) + } + + { + const result = await myCustomCommand(undefined, 'arg2') + expect(result).toEqual({ + testPath: expect.stringMatching('test/browser/test/commands.test.ts'), + arg1: undefined, + arg2: 'arg2', + }) + } + + { + const result = await myCustomCommand(null, 'arg2') + expect(result).toEqual({ + testPath: expect.stringMatching('test/browser/test/commands.test.ts'), + arg1: null, + arg2: 'arg2', + }) + } + + { + const result = await myCustomCommand([undefined, { value: undefined }], 'arg2') + expect(result.arg1).toStrictEqual([undefined, { value: undefined }]) + } }) declare module 'vitest/browser' { interface BrowserCommands { myCustomCommand: ( - arg1: string, + arg1: unknown, arg2: string, ) => Promise<{ testPath: string - arg1: string + arg1: unknown arg2: string }>