mirror of
https://github.com/vitest-dev/vitest.git
synced 2025-12-08 18:26:03 +00:00
chore: always require curly braces (#5885)
Co-authored-by: Ari Perkkiö <ari.perkkio@gmail.com>
This commit is contained in:
parent
66e648ff88
commit
471cf97b0c
@ -29,17 +29,21 @@ export const contributors = (contributorNames).reduce<Contributor[]>((acc, name)
|
||||
|
||||
function createLinks(tm: CoreTeam): CoreTeam {
|
||||
tm.links = [{ icon: 'github', link: `https://github.com/${tm.github}` }]
|
||||
if (tm.mastodon)
|
||||
if (tm.mastodon) {
|
||||
tm.links.push({ icon: 'mastodon', link: tm.mastodon })
|
||||
}
|
||||
|
||||
if (tm.discord)
|
||||
if (tm.discord) {
|
||||
tm.links.push({ icon: 'discord', link: tm.discord })
|
||||
}
|
||||
|
||||
if (tm.youtube)
|
||||
if (tm.youtube) {
|
||||
tm.links.push({ icon: 'youtube', link: `https://www.youtube.com/@${tm.youtube}` })
|
||||
}
|
||||
|
||||
if (tm.twitter)
|
||||
if (tm.twitter) {
|
||||
tm.links.push({ icon: 'x', link: `https://twitter.com/${tm.twitter}` })
|
||||
}
|
||||
|
||||
return tm
|
||||
}
|
||||
|
||||
@ -19,18 +19,22 @@ function resolveOptions(options: CLIOptions<any>, parentName?: string) {
|
||||
}
|
||||
|
||||
function resolveCommand(name: string, config: CLIOption<any> | null): any {
|
||||
if (!config)
|
||||
if (!config) {
|
||||
return null
|
||||
}
|
||||
|
||||
let title = '`'
|
||||
if (config.shorthand)
|
||||
if (config.shorthand) {
|
||||
title += `-${config.shorthand}, `
|
||||
}
|
||||
title += `--${config.alias || name}`
|
||||
if ('argument' in config)
|
||||
if ('argument' in config) {
|
||||
title += ` ${config.argument}`
|
||||
}
|
||||
title += '`'
|
||||
if ('subcommands' in config && config.subcommands)
|
||||
if ('subcommands' in config && config.subcommands) {
|
||||
return resolveOptions(config.subcommands, name)
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
|
||||
@ -8,8 +8,9 @@ const dirAvatars = resolve(docsDir, 'public/user-avatars/')
|
||||
const dirSponsors = resolve(docsDir, 'public/sponsors/')
|
||||
|
||||
async function download(url: string, fileName: string) {
|
||||
if (existsSync(fileName))
|
||||
if (existsSync(fileName)) {
|
||||
return
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('downloading', fileName)
|
||||
try {
|
||||
@ -20,15 +21,17 @@ async function download(url: string, fileName: string) {
|
||||
}
|
||||
|
||||
async function fetchAvatars() {
|
||||
if (!existsSync(dirAvatars))
|
||||
if (!existsSync(dirAvatars)) {
|
||||
await fsp.mkdir(dirAvatars, { recursive: true })
|
||||
}
|
||||
|
||||
await Promise.all([...teamEmeritiMembers, ...teamMembers].map(c => c.github).map(name => download(`https://github.com/${name}.png?size=100`, join(dirAvatars, `${name}.png`))))
|
||||
}
|
||||
|
||||
async function fetchSponsors() {
|
||||
if (!existsSync(dirSponsors))
|
||||
if (!existsSync(dirSponsors)) {
|
||||
await fsp.mkdir(dirSponsors, { recursive: true })
|
||||
}
|
||||
await Promise.all([
|
||||
download('https://cdn.jsdelivr.net/gh/antfu/static/sponsors.svg', join(dirSponsors, 'antfu.svg')),
|
||||
download('https://cdn.jsdelivr.net/gh/patak-dev/static/sponsors.svg', join(dirSponsors, 'patak-dev.svg')),
|
||||
|
||||
@ -10,8 +10,9 @@ import HomePage from '../components/HomePage.vue'
|
||||
import Version from '../components/Version.vue'
|
||||
import '@shikijs/vitepress-twoslash/style.css'
|
||||
|
||||
if (inBrowser)
|
||||
if (inBrowser) {
|
||||
import('./pwa')
|
||||
}
|
||||
|
||||
export default {
|
||||
...Theme,
|
||||
|
||||
@ -263,8 +263,9 @@ This matcher extracts assert value (e.g., `assert v is number`), so you can perf
|
||||
import { expectTypeOf } from 'vitest'
|
||||
|
||||
function assertNumber(v: any): asserts v is number {
|
||||
if (typeof v !== 'number')
|
||||
if (typeof v !== 'number') {
|
||||
throw new TypeError('Nope !')
|
||||
}
|
||||
}
|
||||
|
||||
expectTypeOf(assertNumber).asserts.toBeNumber()
|
||||
|
||||
@ -192,8 +192,9 @@ Opposite of `toBeDefined`, `toBeUndefined` asserts that the value _is_ equal to
|
||||
import { expect, test } from 'vitest'
|
||||
|
||||
function getApplesFromStock(stock: string) {
|
||||
if (stock === 'Bill')
|
||||
if (stock === 'Bill') {
|
||||
return 13
|
||||
}
|
||||
}
|
||||
|
||||
test('mary doesn\'t have a stock', () => {
|
||||
@ -214,8 +215,9 @@ import { Stocks } from './stocks.js'
|
||||
|
||||
const stocks = new Stocks()
|
||||
stocks.sync('Bill')
|
||||
if (stocks.getInfo('Bill'))
|
||||
if (stocks.getInfo('Bill')) {
|
||||
stocks.sell('apples', 'Bill')
|
||||
}
|
||||
```
|
||||
|
||||
So if you want to test that `stocks.getInfo` will be truthy, you could write:
|
||||
@ -247,8 +249,9 @@ import { Stocks } from './stocks.js'
|
||||
|
||||
const stocks = new Stocks()
|
||||
stocks.sync('Bill')
|
||||
if (!stocks.stockFailed('Bill'))
|
||||
if (!stocks.stockFailed('Bill')) {
|
||||
stocks.sell('apples', 'Bill')
|
||||
}
|
||||
```
|
||||
|
||||
So if you want to test that `stocks.stockFailed` will be falsy, you could write:
|
||||
@ -660,8 +663,9 @@ For example, if we want to test that `getFruitStock('pineapples')` throws, we co
|
||||
import { expect, test } from 'vitest'
|
||||
|
||||
function getFruitStock(type: string) {
|
||||
if (type === 'pineapples')
|
||||
if (type === 'pineapples') {
|
||||
throw new Error('Pineapples are not in stock')
|
||||
}
|
||||
|
||||
// Do some other stuff
|
||||
}
|
||||
@ -1203,8 +1207,9 @@ For example, if you have a function that fails when you call it, you may use thi
|
||||
import { expect, test } from 'vitest'
|
||||
|
||||
async function buyApples(id) {
|
||||
if (!id)
|
||||
if (!id) {
|
||||
throw new Error('no id')
|
||||
}
|
||||
}
|
||||
|
||||
test('buyApples throws an error when no id provided', async () => {
|
||||
@ -1301,8 +1306,9 @@ For example, if we want to test that `build()` throws due to receiving directori
|
||||
import { expect, test } from 'vitest'
|
||||
|
||||
async function build(dir) {
|
||||
if (dir.includes('no-src'))
|
||||
if (dir.includes('no-src')) {
|
||||
throw new Error(`${dir}/src does not exist`)
|
||||
}
|
||||
}
|
||||
|
||||
const errorDirs = [
|
||||
@ -1594,14 +1600,15 @@ function areAnagramsEqual(a: unknown, b: unknown): boolean | undefined {
|
||||
const isAAnagramComparator = isAnagramComparator(a)
|
||||
const isBAnagramComparator = isAnagramComparator(b)
|
||||
|
||||
if (isAAnagramComparator && isBAnagramComparator)
|
||||
if (isAAnagramComparator && isBAnagramComparator) {
|
||||
return a.equals(b)
|
||||
|
||||
else if (isAAnagramComparator === isBAnagramComparator)
|
||||
}
|
||||
else if (isAAnagramComparator === isBAnagramComparator) {
|
||||
return undefined
|
||||
|
||||
else
|
||||
}
|
||||
else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
expect.addEqualityTesters([areAnagramsEqual])
|
||||
|
||||
@ -618,8 +618,9 @@ You can also nest describe blocks if you have a hierarchy of tests or benchmarks
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
function numberToCurrency(value: number | string) {
|
||||
if (typeof value !== 'number')
|
||||
throw new Error('Value must be a number')
|
||||
if (typeof value !== 'number') {
|
||||
throw new TypeError('Value must be a number')
|
||||
}
|
||||
|
||||
return value.toFixed(2).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||
}
|
||||
|
||||
@ -673,8 +673,9 @@ let i = 0
|
||||
setTimeout(() => console.log(++i))
|
||||
const interval = setInterval(() => {
|
||||
console.log(++i)
|
||||
if (i === 3)
|
||||
if (i === 3) {
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, 50)
|
||||
|
||||
vi.runAllTimers()
|
||||
@ -818,8 +819,9 @@ test('Server started successfully', async () => {
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
if (!server.isReady)
|
||||
if (!server.isReady) {
|
||||
throw new Error('Server not started')
|
||||
}
|
||||
|
||||
console.log('Server started')
|
||||
},
|
||||
|
||||
@ -2131,12 +2131,14 @@ export default defineConfig({
|
||||
test: {
|
||||
onStackTrace(error: Error, { file }: ParsedStack): boolean | void {
|
||||
// If we've encountered a ReferenceError, show the whole stack.
|
||||
if (error.name === 'ReferenceError')
|
||||
if (error.name === 'ReferenceError') {
|
||||
return
|
||||
}
|
||||
|
||||
// Reject all frames from third party libraries.
|
||||
if (file.includes('node_modules'))
|
||||
if (file.includes('node_modules')) {
|
||||
return false
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@ -27,8 +27,9 @@ function purchase() {
|
||||
const currentHour = new Date().getHours()
|
||||
const [open, close] = businessHours
|
||||
|
||||
if (currentHour > open && currentHour < close)
|
||||
if (currentHour > open && currentHour < close) {
|
||||
return { message: 'Success' }
|
||||
}
|
||||
|
||||
return { message: 'Error' }
|
||||
}
|
||||
@ -194,8 +195,9 @@ export default {
|
||||
{
|
||||
name: 'virtual-modules',
|
||||
resolveId(id) {
|
||||
if (id === '$app/forms')
|
||||
if (id === '$app/forms') {
|
||||
return 'virtual:$app/forms'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@ -45,6 +45,7 @@ export default antfu(
|
||||
'no-undef': 'off',
|
||||
'ts/no-invalid-this': 'off',
|
||||
'eslint-comments/no-unlimited-disable': 'off',
|
||||
'curly': ['error', 'all'],
|
||||
|
||||
// TODO: migrate and turn it back on
|
||||
'ts/ban-types': 'off',
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { dev } from '$app/environment'
|
||||
|
||||
export function add(a: number, b: number) {
|
||||
if (dev)
|
||||
if (dev) {
|
||||
console.warn(`Adding ${a} and ${b}`)
|
||||
}
|
||||
|
||||
return a + b
|
||||
}
|
||||
|
||||
@ -55,6 +55,7 @@
|
||||
"lint-staged": "^15.2.5",
|
||||
"magic-string": "^0.30.10",
|
||||
"pathe": "^1.1.2",
|
||||
"prettier": "^2.8.8",
|
||||
"rimraf": "^5.0.7",
|
||||
"rollup": "^4.18.0",
|
||||
"rollup-plugin-dts": "^6.1.1",
|
||||
|
||||
33
packages/browser/context.d.ts
vendored
33
packages/browser/context.d.ts
vendored
@ -19,12 +19,24 @@ export interface FsOptions {
|
||||
flag?: string | number
|
||||
}
|
||||
|
||||
export interface TypePayload { type: string }
|
||||
export interface PressPayload { press: string }
|
||||
export interface DownPayload { down: string }
|
||||
export interface UpPayload { up: string }
|
||||
export interface TypePayload {
|
||||
type: string
|
||||
}
|
||||
export interface PressPayload {
|
||||
press: string
|
||||
}
|
||||
export interface DownPayload {
|
||||
down: string
|
||||
}
|
||||
export interface UpPayload {
|
||||
up: string
|
||||
}
|
||||
|
||||
export type SendKeysPayload = TypePayload | PressPayload | DownPayload | UpPayload
|
||||
export type SendKeysPayload =
|
||||
| TypePayload
|
||||
| PressPayload
|
||||
| DownPayload
|
||||
| UpPayload
|
||||
|
||||
export interface ScreenshotOptions {
|
||||
element?: Element
|
||||
@ -35,8 +47,15 @@ export interface ScreenshotOptions {
|
||||
}
|
||||
|
||||
export interface BrowserCommands {
|
||||
readFile: (path: string, options?: BufferEncoding | FsOptions) => Promise<string>
|
||||
writeFile: (path: string, content: string, options?: BufferEncoding | FsOptions & { mode?: number | string }) => Promise<void>
|
||||
readFile: (
|
||||
path: string,
|
||||
options?: BufferEncoding | FsOptions
|
||||
) => Promise<string>
|
||||
writeFile: (
|
||||
path: string,
|
||||
content: string,
|
||||
options?: BufferEncoding | (FsOptions & { mode?: number | string })
|
||||
) => Promise<void>
|
||||
removeFile: (path: string) => Promise<void>
|
||||
sendKeys: (payload: SendKeysPayload) => Promise<void>
|
||||
}
|
||||
|
||||
5
packages/browser/providers/playwright.d.ts
vendored
5
packages/browser/providers/playwright.d.ts
vendored
@ -9,7 +9,10 @@ import type {
|
||||
declare module 'vitest/node' {
|
||||
interface BrowserProviderOptions {
|
||||
launch?: LaunchOptions
|
||||
context?: Omit<BrowserContextOptions, 'ignoreHTTPSErrors' | 'serviceWorkers'>
|
||||
context?: Omit<
|
||||
BrowserContextOptions,
|
||||
'ignoreHTTPSErrors' | 'serviceWorkers'
|
||||
>
|
||||
}
|
||||
|
||||
export interface BrowserCommandContext {
|
||||
|
||||
@ -33,35 +33,36 @@ const input = {
|
||||
providers: './src/node/providers/index.ts',
|
||||
}
|
||||
|
||||
export default () => defineConfig([
|
||||
{
|
||||
input,
|
||||
output: {
|
||||
dir: 'dist',
|
||||
format: 'esm',
|
||||
export default () =>
|
||||
defineConfig([
|
||||
{
|
||||
input,
|
||||
output: {
|
||||
dir: 'dist',
|
||||
format: 'esm',
|
||||
},
|
||||
external,
|
||||
plugins,
|
||||
},
|
||||
external,
|
||||
plugins,
|
||||
},
|
||||
{
|
||||
input: './src/client/context.ts',
|
||||
output: {
|
||||
file: 'dist/context.js',
|
||||
format: 'esm',
|
||||
{
|
||||
input: './src/client/context.ts',
|
||||
output: {
|
||||
file: 'dist/context.js',
|
||||
format: 'esm',
|
||||
},
|
||||
plugins: [
|
||||
esbuild({
|
||||
target: 'node18',
|
||||
}),
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
esbuild({
|
||||
target: 'node18',
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
input: input.index,
|
||||
output: {
|
||||
file: 'dist/index.d.ts',
|
||||
format: 'esm',
|
||||
{
|
||||
input: input.index,
|
||||
output: {
|
||||
file: 'dist/index.d.ts',
|
||||
format: 'esm',
|
||||
},
|
||||
external,
|
||||
plugins: [dts()],
|
||||
},
|
||||
external,
|
||||
plugins: [dts()],
|
||||
},
|
||||
])
|
||||
])
|
||||
|
||||
@ -78,13 +78,20 @@ export type IframeChannelEvent =
|
||||
| IframeChannelIncomingEvent
|
||||
| IframeChannelOutgoingEvent
|
||||
|
||||
export const channel = new BroadcastChannel(`vitest:${getBrowserState().contextId}`)
|
||||
export const channel = new BroadcastChannel(
|
||||
`vitest:${getBrowserState().contextId}`,
|
||||
)
|
||||
|
||||
export function waitForChannel(event: IframeChannelOutgoingEvent['type']) {
|
||||
return new Promise<void>((resolve) => {
|
||||
channel.addEventListener('message', (e) => {
|
||||
if (e.data?.type === event)
|
||||
resolve()
|
||||
}, { once: true })
|
||||
channel.addEventListener(
|
||||
'message',
|
||||
(e) => {
|
||||
if (e.data?.type === event) {
|
||||
resolve()
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@ -9,9 +9,10 @@ const PAGE_TYPE = getBrowserState().type
|
||||
|
||||
export const PORT = import.meta.hot ? '51204' : location.port
|
||||
export const HOST = [location.hostname, PORT].filter(Boolean).join(':')
|
||||
export const SESSION_ID = PAGE_TYPE === 'orchestrator'
|
||||
? getBrowserState().contextId
|
||||
: crypto.randomUUID()
|
||||
export const SESSION_ID
|
||||
= PAGE_TYPE === 'orchestrator'
|
||||
? getBrowserState().contextId
|
||||
: crypto.randomUUID()
|
||||
export const ENTRY_URL = `${
|
||||
location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
}//${HOST}/__vitest_browser_api__?type=${PAGE_TYPE}&sessionId=${SESSION_ID}`
|
||||
@ -27,7 +28,10 @@ export interface VitestBrowserClient {
|
||||
waitForConnection: () => Promise<void>
|
||||
}
|
||||
|
||||
export type BrowserRPC = BirpcReturn<WebSocketBrowserHandlers, WebSocketBrowserEvents>
|
||||
export type BrowserRPC = BirpcReturn<
|
||||
WebSocketBrowserHandlers,
|
||||
WebSocketBrowserEvents
|
||||
>
|
||||
|
||||
function createClient() {
|
||||
const autoReconnect = true
|
||||
@ -44,46 +48,55 @@ function createClient() {
|
||||
|
||||
let onMessage: Function
|
||||
|
||||
ctx.rpc = createBirpc<WebSocketBrowserHandlers, WebSocketBrowserEvents>({
|
||||
onCancel: setCancel,
|
||||
async startMocking(id: string) {
|
||||
// @ts-expect-error not typed global
|
||||
if (typeof __vitest_mocker__ === 'undefined')
|
||||
throw new Error(`Cannot mock modules in the orchestrator process`)
|
||||
// @ts-expect-error not typed global
|
||||
const mocker = __vitest_mocker__ as VitestBrowserClientMocker
|
||||
const exports = await mocker.resolve(id)
|
||||
return Object.keys(exports)
|
||||
},
|
||||
async createTesters(files: string[]) {
|
||||
if (PAGE_TYPE !== 'orchestrator')
|
||||
return
|
||||
getBrowserState().createTesters?.(files)
|
||||
},
|
||||
}, {
|
||||
post: msg => ctx.ws.send(msg),
|
||||
on: fn => (onMessage = fn),
|
||||
serialize: e => stringify(e, (_, v) => {
|
||||
if (v instanceof Error) {
|
||||
return {
|
||||
name: v.name,
|
||||
message: v.message,
|
||||
stack: v.stack,
|
||||
ctx.rpc = createBirpc<WebSocketBrowserHandlers, WebSocketBrowserEvents>(
|
||||
{
|
||||
onCancel: setCancel,
|
||||
async startMocking(id: string) {
|
||||
// @ts-expect-error not typed global
|
||||
if (typeof __vitest_mocker__ === 'undefined') {
|
||||
throw new TypeError(
|
||||
`Cannot mock modules in the orchestrator process`,
|
||||
)
|
||||
}
|
||||
}
|
||||
return v
|
||||
}),
|
||||
deserialize: parse,
|
||||
onTimeoutError(functionName) {
|
||||
throw new Error(`[vitest-browser]: Timeout calling "${functionName}"`)
|
||||
// @ts-expect-error not typed global
|
||||
const mocker = __vitest_mocker__ as VitestBrowserClientMocker
|
||||
const exports = await mocker.resolve(id)
|
||||
return Object.keys(exports)
|
||||
},
|
||||
async createTesters(files: string[]) {
|
||||
if (PAGE_TYPE !== 'orchestrator') {
|
||||
return
|
||||
}
|
||||
getBrowserState().createTesters?.(files)
|
||||
},
|
||||
},
|
||||
})
|
||||
{
|
||||
post: msg => ctx.ws.send(msg),
|
||||
on: fn => (onMessage = fn),
|
||||
serialize: e =>
|
||||
stringify(e, (_, v) => {
|
||||
if (v instanceof Error) {
|
||||
return {
|
||||
name: v.name,
|
||||
message: v.message,
|
||||
stack: v.stack,
|
||||
}
|
||||
}
|
||||
return v
|
||||
}),
|
||||
deserialize: parse,
|
||||
onTimeoutError(functionName) {
|
||||
throw new Error(`[vitest-browser]: Timeout calling "${functionName}"`)
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
let openPromise: Promise<void>
|
||||
|
||||
function reconnect(reset = false) {
|
||||
if (reset)
|
||||
if (reset) {
|
||||
tries = reconnectTries
|
||||
}
|
||||
ctx.ws = new WebSocket(ENTRY_URL)
|
||||
registerWS()
|
||||
}
|
||||
@ -91,10 +104,15 @@ function createClient() {
|
||||
function registerWS() {
|
||||
openPromise = new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error(`Cannot connect to the server in ${connectTimeout / 1000} seconds`))
|
||||
reject(
|
||||
new Error(
|
||||
`Cannot connect to the server in ${connectTimeout / 1000} seconds`,
|
||||
),
|
||||
)
|
||||
}, connectTimeout)?.unref?.()
|
||||
if (ctx.ws.OPEN === ctx.ws.readyState)
|
||||
if (ctx.ws.OPEN === ctx.ws.readyState) {
|
||||
resolve()
|
||||
}
|
||||
// still have a listener even if it's already open to update tries
|
||||
ctx.ws.addEventListener('open', () => {
|
||||
tries = reconnectTries
|
||||
@ -107,8 +125,9 @@ function createClient() {
|
||||
})
|
||||
ctx.ws.addEventListener('close', () => {
|
||||
tries -= 1
|
||||
if (autoReconnect && tries > 0)
|
||||
if (autoReconnect && tries > 0) {
|
||||
setTimeout(reconnect, reconnectInterval)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -1,32 +1,48 @@
|
||||
import type { Task, WorkerGlobalState } from 'vitest'
|
||||
import type { BrowserPage, UserEvent, UserEventClickOptions } from '../../context'
|
||||
import type {
|
||||
BrowserPage,
|
||||
UserEvent,
|
||||
UserEventClickOptions,
|
||||
} from '../../context'
|
||||
import type { BrowserRPC } from './client'
|
||||
import type { BrowserRunnerState } from './utils'
|
||||
|
||||
// this file should not import anything directly, only types
|
||||
|
||||
function convertElementToXPath(element: Element) {
|
||||
if (!element || !(element instanceof Element))
|
||||
throw new Error(`Expected DOM element to be an instance of Element, received ${typeof element}`)
|
||||
if (!element || !(element instanceof Element)) {
|
||||
throw new Error(
|
||||
`Expected DOM element to be an instance of Element, received ${typeof element}`,
|
||||
)
|
||||
}
|
||||
|
||||
return getPathTo(element)
|
||||
}
|
||||
|
||||
function getPathTo(element: Element): string {
|
||||
if (element.id !== '')
|
||||
if (element.id !== '') {
|
||||
return `id("${element.id}")`
|
||||
}
|
||||
|
||||
if (!element.parentNode || element === document.documentElement)
|
||||
if (!element.parentNode || element === document.documentElement) {
|
||||
return element.tagName
|
||||
}
|
||||
|
||||
let ix = 0
|
||||
const siblings = element.parentNode.childNodes
|
||||
for (let i = 0; i < siblings.length; i++) {
|
||||
const sibling = siblings[i]
|
||||
if (sibling === element)
|
||||
return `${getPathTo(element.parentNode as Element)}/${element.tagName}[${ix + 1}]`
|
||||
if (sibling.nodeType === 1 && (sibling as Element).tagName === element.tagName)
|
||||
if (sibling === element) {
|
||||
return `${getPathTo(element.parentNode as Element)}/${element.tagName}[${
|
||||
ix + 1
|
||||
}]`
|
||||
}
|
||||
if (
|
||||
sibling.nodeType === 1
|
||||
&& (sibling as Element).tagName === element.tagName
|
||||
) {
|
||||
ix++
|
||||
}
|
||||
}
|
||||
return 'invalid xpath'
|
||||
}
|
||||
@ -35,7 +51,9 @@ function getPathTo(element: Element): string {
|
||||
const state = (): WorkerGlobalState => __vitest_worker__
|
||||
// @ts-expect-error not typed global
|
||||
const runner = (): BrowserRunnerState => __vitest_browser_runner__
|
||||
const filepath = () => state().filepath || state().current?.file?.filepath || undefined
|
||||
function filepath() {
|
||||
return state().filepath || state().current?.file?.filepath || undefined
|
||||
}
|
||||
const rpc = () => state().rpc as any as BrowserRPC
|
||||
const contextId = runner().contextId
|
||||
const channel = new BroadcastChannel(`vitest:${contextId}`)
|
||||
@ -74,8 +92,9 @@ export const page: BrowserPage = {
|
||||
},
|
||||
async screenshot(options = {}) {
|
||||
const currentTest = state().current
|
||||
if (!currentTest)
|
||||
if (!currentTest) {
|
||||
throw new Error('Cannot take a screenshot outside of a test.')
|
||||
}
|
||||
|
||||
if (currentTest.concurrent) {
|
||||
throw new Error(
|
||||
@ -92,16 +111,15 @@ export const page: BrowserPage = {
|
||||
screenshotIds[repeatCount] ??= {}
|
||||
screenshotIds[repeatCount][taskName] = number + 1
|
||||
|
||||
const name = options.path || `${taskName.replace(/[^a-z0-9]/g, '-')}-${number}.png`
|
||||
const name
|
||||
= options.path || `${taskName.replace(/[^a-z0-9]/g, '-')}-${number}.png`
|
||||
|
||||
return triggerCommand(
|
||||
'__vitest_screenshot',
|
||||
name,
|
||||
{
|
||||
...options,
|
||||
element: options.element ? convertElementToXPath(options.element) : undefined,
|
||||
},
|
||||
)
|
||||
return triggerCommand('__vitest_screenshot', name, {
|
||||
...options,
|
||||
element: options.element
|
||||
? convertElementToXPath(options.element)
|
||||
: undefined,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -8,7 +8,9 @@ If needed, mock the \`${name}\` call manually like:
|
||||
\`\`\`
|
||||
import { expect, vi } from "vitest"
|
||||
|
||||
vi.spyOn(window, "${name}")${defaultValue ? `.mockReturnValue(${JSON.stringify(defaultValue)})` : ''}
|
||||
vi.spyOn(window, "${name}")${
|
||||
defaultValue ? `.mockReturnValue(${JSON.stringify(defaultValue)})` : ''
|
||||
}
|
||||
${name}(${formatedParams})
|
||||
expect(${name}).toHaveBeenCalledWith(${formatedParams})
|
||||
\`\`\``)
|
||||
|
||||
@ -4,23 +4,46 @@ import { getConfig, importId } from './utils'
|
||||
const { Date, console } = globalThis
|
||||
|
||||
export async function setupConsoleLogSpy() {
|
||||
const { stringify, format, inspect } = await importId('vitest/utils') as typeof import('vitest/utils')
|
||||
const { log, info, error, dir, dirxml, trace, time, timeEnd, timeLog, warn, debug, count, countReset } = console
|
||||
const { stringify, format, inspect } = (await importId(
|
||||
'vitest/utils',
|
||||
)) as typeof import('vitest/utils')
|
||||
const {
|
||||
log,
|
||||
info,
|
||||
error,
|
||||
dir,
|
||||
dirxml,
|
||||
trace,
|
||||
time,
|
||||
timeEnd,
|
||||
timeLog,
|
||||
warn,
|
||||
debug,
|
||||
count,
|
||||
countReset,
|
||||
} = console
|
||||
const formatInput = (input: unknown) => {
|
||||
if (input instanceof Node)
|
||||
if (input instanceof Node) {
|
||||
return stringify(input)
|
||||
}
|
||||
return format(input)
|
||||
}
|
||||
const processLog = (args: unknown[]) => args.map(formatInput).join(' ')
|
||||
const sendLog = (type: 'stdout' | 'stderr', content: string, disableStack?: boolean) => {
|
||||
if (content.startsWith('[vite]'))
|
||||
const sendLog = (
|
||||
type: 'stdout' | 'stderr',
|
||||
content: string,
|
||||
disableStack?: boolean,
|
||||
) => {
|
||||
if (content.startsWith('[vite]')) {
|
||||
return
|
||||
}
|
||||
const unknownTestId = '__vitest__unknown_test__'
|
||||
// @ts-expect-error untyped global
|
||||
const taskId = globalThis.__vitest_worker__?.current?.id ?? unknownTestId
|
||||
const origin = getConfig().printConsoleTrace && !disableStack
|
||||
? new Error('STACK_TRACE').stack?.split('\n').slice(1).join('\n')
|
||||
: undefined
|
||||
const origin
|
||||
= getConfig().printConsoleTrace && !disableStack
|
||||
? new Error('STACK_TRACE').stack?.split('\n').slice(1).join('\n')
|
||||
: undefined
|
||||
rpc().sendLog({
|
||||
origin,
|
||||
content,
|
||||
@ -31,14 +54,18 @@ export async function setupConsoleLogSpy() {
|
||||
size: content.length,
|
||||
})
|
||||
}
|
||||
const stdout = (base: (...args: unknown[]) => void) => (...args: unknown[]) => {
|
||||
sendLog('stdout', processLog(args))
|
||||
return base(...args)
|
||||
}
|
||||
const stderr = (base: (...args: unknown[]) => void) => (...args: unknown[]) => {
|
||||
sendLog('stderr', processLog(args))
|
||||
return base(...args)
|
||||
}
|
||||
const stdout
|
||||
= (base: (...args: unknown[]) => void) =>
|
||||
(...args: unknown[]) => {
|
||||
sendLog('stdout', processLog(args))
|
||||
return base(...args)
|
||||
}
|
||||
const stderr
|
||||
= (base: (...args: unknown[]) => void) =>
|
||||
(...args: unknown[]) => {
|
||||
sendLog('stderr', processLog(args))
|
||||
return base(...args)
|
||||
}
|
||||
console.log = stdout(log)
|
||||
console.debug = stdout(debug)
|
||||
console.info = stdout(info)
|
||||
@ -77,10 +104,12 @@ export async function setupConsoleLogSpy() {
|
||||
|
||||
console.timeLog = (label = 'default') => {
|
||||
timeLog(label)
|
||||
if (!(label in timeLabels))
|
||||
if (!(label in timeLabels)) {
|
||||
sendLog('stderr', `Timer "${label}" does not exist`)
|
||||
else
|
||||
}
|
||||
else {
|
||||
sendLog('stdout', `${label}: ${timeLabels[label]} ms`)
|
||||
}
|
||||
}
|
||||
|
||||
console.timeEnd = (label = 'default') => {
|
||||
|
||||
@ -21,25 +21,30 @@ export class VitestBrowserClientMocker {
|
||||
private spyModule!: SpyModule
|
||||
|
||||
setupWorker() {
|
||||
channel.addEventListener('message', async (e: MessageEvent<IframeChannelOutgoingEvent>) => {
|
||||
if (e.data.type === 'mock-factory:request') {
|
||||
try {
|
||||
const module = await this.resolve(e.data.id)
|
||||
const exports = Object.keys(module)
|
||||
channel.postMessage({
|
||||
type: 'mock-factory:response',
|
||||
exports,
|
||||
})
|
||||
channel.addEventListener(
|
||||
'message',
|
||||
async (e: MessageEvent<IframeChannelOutgoingEvent>) => {
|
||||
if (e.data.type === 'mock-factory:request') {
|
||||
try {
|
||||
const module = await this.resolve(e.data.id)
|
||||
const exports = Object.keys(module)
|
||||
channel.postMessage({
|
||||
type: 'mock-factory:response',
|
||||
exports,
|
||||
})
|
||||
}
|
||||
catch (err: any) {
|
||||
const { processError } = (await importId(
|
||||
'vitest/browser',
|
||||
)) as typeof import('vitest/browser')
|
||||
channel.postMessage({
|
||||
type: 'mock-factory:error',
|
||||
error: processError(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
catch (err: any) {
|
||||
const { processError } = await importId('vitest/browser') as typeof import('vitest/browser')
|
||||
channel.postMessage({
|
||||
type: 'mock-factory:error',
|
||||
error: processError(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
public setSpyModule(mod: SpyModule) {
|
||||
@ -48,8 +53,11 @@ export class VitestBrowserClientMocker {
|
||||
|
||||
public async importActual(id: string, importer: string) {
|
||||
const resolved = await rpc().resolveId(id, importer)
|
||||
if (resolved == null)
|
||||
throw new Error(`[vitest] Cannot resolve ${id} imported from ${importer}`)
|
||||
if (resolved == null) {
|
||||
throw new Error(
|
||||
`[vitest] Cannot resolve ${id} imported from ${importer}`,
|
||||
)
|
||||
}
|
||||
const ext = extname(resolved.id)
|
||||
const url = new URL(`/@id/${resolved.id}`, location.href)
|
||||
const query = `_vitest_original&ext.${ext}`
|
||||
@ -61,14 +69,20 @@ export class VitestBrowserClientMocker {
|
||||
|
||||
public async importMock(rawId: string, importer: string) {
|
||||
await this.prepare()
|
||||
const { resolvedId, type, mockPath } = await rpc().resolveMock(rawId, importer, false)
|
||||
const { resolvedId, type, mockPath } = await rpc().resolveMock(
|
||||
rawId,
|
||||
importer,
|
||||
false,
|
||||
)
|
||||
|
||||
const factoryReturn = this.get(resolvedId)
|
||||
if (factoryReturn)
|
||||
if (factoryReturn) {
|
||||
return factoryReturn
|
||||
}
|
||||
|
||||
if (this.factories[resolvedId])
|
||||
if (this.factories[resolvedId]) {
|
||||
return await this.resolve(resolvedId)
|
||||
}
|
||||
|
||||
if (type === 'redirect') {
|
||||
const url = new URL(`/@id/${mockPath}`, location.href)
|
||||
@ -90,8 +104,9 @@ export class VitestBrowserClientMocker {
|
||||
|
||||
public async invalidate() {
|
||||
const ids = Array.from(this.ids)
|
||||
if (!ids.length)
|
||||
if (!ids.length) {
|
||||
return
|
||||
}
|
||||
await rpc().invalidate(ids)
|
||||
channel.postMessage({ type: 'mock:invalidate' })
|
||||
this.ids.clear()
|
||||
@ -102,8 +117,9 @@ export class VitestBrowserClientMocker {
|
||||
|
||||
public async resolve(id: string) {
|
||||
const factory = this.factories[id]
|
||||
if (!factory)
|
||||
if (!factory) {
|
||||
throw new Error(`Cannot resolve ${id} mock: no factory provided`)
|
||||
}
|
||||
try {
|
||||
this.mockObjects[id] = await factory()
|
||||
return this.mockObjects[id]
|
||||
@ -120,13 +136,15 @@ export class VitestBrowserClientMocker {
|
||||
}
|
||||
|
||||
public queueMock(id: string, importer: string, factory?: () => any) {
|
||||
const promise = rpc().resolveMock(id, importer, !!factory)
|
||||
const promise = rpc()
|
||||
.resolveMock(id, importer, !!factory)
|
||||
.then(async ({ mockPath, resolvedId }) => {
|
||||
this.ids.add(resolvedId)
|
||||
const urlPaths = resolveMockPaths(resolvedId)
|
||||
const resolvedMock = typeof mockPath === 'string'
|
||||
? new URL(resolvedMockedPath(mockPath), location.href).toString()
|
||||
: mockPath
|
||||
const resolvedMock
|
||||
= typeof mockPath === 'string'
|
||||
? new URL(resolvedMockedPath(mockPath), location.href).toString()
|
||||
: mockPath
|
||||
urlPaths.forEach((url) => {
|
||||
this.mocks[url] = resolvedMock
|
||||
this.factories[url] = factory!
|
||||
@ -137,17 +155,20 @@ export class VitestBrowserClientMocker {
|
||||
mock: resolvedMock,
|
||||
})
|
||||
await waitForChannel('mock:done')
|
||||
}).finally(() => {
|
||||
})
|
||||
.finally(() => {
|
||||
this.queue.delete(promise)
|
||||
})
|
||||
this.queue.add(promise)
|
||||
}
|
||||
|
||||
public queueUnmock(id: string, importer: string) {
|
||||
const promise = rpc().resolveId(id, importer)
|
||||
const promise = rpc()
|
||||
.resolveId(id, importer)
|
||||
.then(async (resolved) => {
|
||||
if (!resolved)
|
||||
if (!resolved) {
|
||||
return
|
||||
}
|
||||
this.ids.delete(resolved.id)
|
||||
const urlPaths = resolveMockPaths(resolved.id)
|
||||
urlPaths.forEach((url) => {
|
||||
@ -168,15 +189,17 @@ export class VitestBrowserClientMocker {
|
||||
}
|
||||
|
||||
public async prepare() {
|
||||
if (!this.queue.size)
|
||||
if (!this.queue.size) {
|
||||
return
|
||||
await Promise.all([
|
||||
...this.queue.values(),
|
||||
])
|
||||
}
|
||||
await Promise.all([...this.queue.values()])
|
||||
}
|
||||
|
||||
// TODO: move this logic into a util(?)
|
||||
public mockObject(object: Record<Key, any>, mockExports: Record<Key, any> = {}) {
|
||||
public mockObject(
|
||||
object: Record<Key, any>,
|
||||
mockExports: Record<Key, any> = {},
|
||||
) {
|
||||
const finalizers = new Array<() => void>()
|
||||
const refs = new RefTracker()
|
||||
|
||||
@ -190,10 +213,16 @@ export class VitestBrowserClientMocker {
|
||||
}
|
||||
}
|
||||
|
||||
const mockPropertiesOf = (container: Record<Key, any>, newContainer: Record<Key, any>) => {
|
||||
const mockPropertiesOf = (
|
||||
container: Record<Key, any>,
|
||||
newContainer: Record<Key, any>,
|
||||
) => {
|
||||
const containerType = /* #__PURE__ */ getType(container)
|
||||
const isModule = containerType === 'Module' || !!container.__esModule
|
||||
for (const { key: property, descriptor } of getAllMockableProperties(container, isModule)) {
|
||||
for (const { key: property, descriptor } of getAllMockableProperties(
|
||||
container,
|
||||
isModule,
|
||||
)) {
|
||||
// Modules define their exports as getters. We want to process those.
|
||||
if (!isModule && descriptor.get) {
|
||||
try {
|
||||
@ -206,8 +235,9 @@ export class VitestBrowserClientMocker {
|
||||
}
|
||||
|
||||
// Skip special read-only props, we don't want to mess with those.
|
||||
if (isSpecialProp(property, containerType))
|
||||
if (isSpecialProp(property, containerType)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const value = container[property]
|
||||
|
||||
@ -215,7 +245,9 @@ export class VitestBrowserClientMocker {
|
||||
// recursion in circular objects.
|
||||
const refId = refs.getId(value)
|
||||
if (refId !== undefined) {
|
||||
finalizers.push(() => define(newContainer, property, refs.getMockedValue(refId)))
|
||||
finalizers.push(() =>
|
||||
define(newContainer, property, refs.getMockedValue(refId)),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
@ -226,38 +258,54 @@ export class VitestBrowserClientMocker {
|
||||
continue
|
||||
}
|
||||
|
||||
const isFunction = type.includes('Function') && typeof value === 'function'
|
||||
if ((!isFunction || value.__isMockFunction) && type !== 'Object' && type !== 'Module') {
|
||||
const isFunction
|
||||
= type.includes('Function') && typeof value === 'function'
|
||||
if (
|
||||
(!isFunction || value.__isMockFunction)
|
||||
&& type !== 'Object'
|
||||
&& type !== 'Module'
|
||||
) {
|
||||
define(newContainer, property, value)
|
||||
continue
|
||||
}
|
||||
|
||||
// Sometimes this assignment fails for some unknown reason. If it does,
|
||||
// just move along.
|
||||
if (!define(newContainer, property, isFunction ? value : {}))
|
||||
if (!define(newContainer, property, isFunction ? value : {})) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (isFunction) {
|
||||
const spyModule = this.spyModule
|
||||
if (!spyModule)
|
||||
throw new Error('[vitest] `spyModule` is not defined. This is Vitest error. Please open a new issue with reproduction.')
|
||||
if (!spyModule) {
|
||||
throw new Error(
|
||||
'[vitest] `spyModule` is not defined. This is Vitest error. Please open a new issue with reproduction.',
|
||||
)
|
||||
}
|
||||
function mockFunction(this: any) {
|
||||
// detect constructor call and mock each instance's methods
|
||||
// so that mock states between prototype/instances don't affect each other
|
||||
// (jest reference https://github.com/jestjs/jest/blob/2c3d2409879952157433de215ae0eee5188a4384/packages/jest-mock/src/index.ts#L678-L691)
|
||||
if (this instanceof newContainer[property]) {
|
||||
for (const { key, descriptor } of getAllMockableProperties(this, false)) {
|
||||
for (const { key, descriptor } of getAllMockableProperties(
|
||||
this,
|
||||
false,
|
||||
)) {
|
||||
// skip getter since it's not mocked on prototype as well
|
||||
if (descriptor.get)
|
||||
if (descriptor.get) {
|
||||
continue
|
||||
}
|
||||
|
||||
const value = this[key]
|
||||
const type = /* #__PURE__ */ getType(value)
|
||||
const isFunction = type.includes('Function') && typeof value === 'function'
|
||||
const isFunction
|
||||
= type.includes('Function') && typeof value === 'function'
|
||||
if (isFunction) {
|
||||
// mock and delegate calls to original prototype method, which should be also mocked already
|
||||
const original = this[key]
|
||||
const mock = spyModule.spyOn(this, key as string).mockImplementation(original)
|
||||
const mock = spyModule
|
||||
.spyOn(this, key as string)
|
||||
.mockImplementation(original)
|
||||
mock.mockRestore = () => {
|
||||
mock.mockReset()
|
||||
mock.mockImplementation(original)
|
||||
@ -267,7 +315,9 @@ export class VitestBrowserClientMocker {
|
||||
}
|
||||
}
|
||||
}
|
||||
const mock = spyModule.spyOn(newContainer, property).mockImplementation(mockFunction)
|
||||
const mock = spyModule
|
||||
.spyOn(newContainer, property)
|
||||
.mockImplementation(mockFunction)
|
||||
mock.mockRestore = () => {
|
||||
mock.mockReset()
|
||||
mock.mockImplementation(mockFunction)
|
||||
@ -286,17 +336,20 @@ export class VitestBrowserClientMocker {
|
||||
mockPropertiesOf(object, mockedObject)
|
||||
|
||||
// Plug together refs
|
||||
for (const finalizer of finalizers)
|
||||
for (const finalizer of finalizers) {
|
||||
finalizer()
|
||||
}
|
||||
|
||||
return mockedObject
|
||||
}
|
||||
}
|
||||
|
||||
function isSpecialProp(prop: Key, parentType: string) {
|
||||
return parentType.includes('Function')
|
||||
return (
|
||||
parentType.includes('Function')
|
||||
&& typeof prop === 'string'
|
||||
&& ['arguments', 'callee', 'caller', 'length', 'name'].includes(prop)
|
||||
)
|
||||
}
|
||||
|
||||
class RefTracker {
|
||||
@ -322,39 +375,56 @@ class RefTracker {
|
||||
type Key = string | symbol
|
||||
|
||||
export function getAllMockableProperties(obj: any, isModule: boolean) {
|
||||
const allProps = new Map<string | symbol, { key: string | symbol; descriptor: PropertyDescriptor }>()
|
||||
const allProps = new Map<
|
||||
string | symbol,
|
||||
{ key: string | symbol; descriptor: PropertyDescriptor }
|
||||
>()
|
||||
let curr = obj
|
||||
do {
|
||||
// we don't need properties from these
|
||||
if (curr === Object.prototype || curr === Function.prototype || curr === RegExp.prototype)
|
||||
if (
|
||||
curr === Object.prototype
|
||||
|| curr === Function.prototype
|
||||
|| curr === RegExp.prototype
|
||||
) {
|
||||
break
|
||||
}
|
||||
|
||||
collectOwnProperties(curr, (key) => {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(curr, key)
|
||||
if (descriptor)
|
||||
if (descriptor) {
|
||||
allProps.set(key, { key, descriptor })
|
||||
}
|
||||
})
|
||||
// eslint-disable-next-line no-cond-assign
|
||||
} while (curr = Object.getPrototypeOf(curr))
|
||||
// eslint-disable-next-line no-cond-assign
|
||||
} while ((curr = Object.getPrototypeOf(curr)))
|
||||
// default is not specified in ownKeys, if module is interoped
|
||||
if (isModule && !allProps.has('default') && 'default' in obj) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(obj, 'default')
|
||||
if (descriptor)
|
||||
if (descriptor) {
|
||||
allProps.set('default', { key: 'default', descriptor })
|
||||
}
|
||||
}
|
||||
return Array.from(allProps.values())
|
||||
}
|
||||
|
||||
function collectOwnProperties(obj: any, collector: Set<string | symbol> | ((key: string | symbol) => void)) {
|
||||
const collect = typeof collector === 'function' ? collector : (key: string | symbol) => collector.add(key)
|
||||
function collectOwnProperties(
|
||||
obj: any,
|
||||
collector: Set<string | symbol> | ((key: string | symbol) => void),
|
||||
) {
|
||||
const collect
|
||||
= typeof collector === 'function'
|
||||
? collector
|
||||
: (key: string | symbol) => collector.add(key)
|
||||
Object.getOwnPropertyNames(obj).forEach(collect)
|
||||
Object.getOwnPropertySymbols(obj).forEach(collect)
|
||||
}
|
||||
|
||||
function resolvedMockedPath(path: string) {
|
||||
const config = getBrowserState().viteConfig
|
||||
if (path.startsWith(config.root))
|
||||
if (path.startsWith(config.root)) {
|
||||
return path.slice(config.root.length)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
@ -365,11 +435,13 @@ function resolveMockPaths(path: string) {
|
||||
const paths = [path, join('/@fs/', path)]
|
||||
|
||||
// URL can be /file/path.js, but path is resolved to /file/path
|
||||
if (path.startsWith(config.root))
|
||||
if (path.startsWith(config.root)) {
|
||||
paths.push(path.slice(config.root.length))
|
||||
}
|
||||
|
||||
if (path.startsWith(fsRoot))
|
||||
if (path.startsWith(fsRoot)) {
|
||||
paths.push(path.slice(fsRoot.length))
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import { http } from 'msw/core/http'
|
||||
import { setupWorker } from 'msw/browser'
|
||||
import type { IframeChannelEvent, IframeMockEvent, IframeMockingDoneEvent, IframeUnmockEvent } from './channel'
|
||||
import type {
|
||||
IframeChannelEvent,
|
||||
IframeMockEvent,
|
||||
IframeMockingDoneEvent,
|
||||
IframeUnmockEvent,
|
||||
} from './channel'
|
||||
import { channel } from './channel'
|
||||
import { client } from './client'
|
||||
|
||||
@ -10,8 +15,9 @@ export function createModuleMocker() {
|
||||
const worker = setupWorker(
|
||||
http.get(/.+/, async ({ request }) => {
|
||||
const path = removeTimestamp(request.url.slice(location.origin.length))
|
||||
if (!mocks.has(path))
|
||||
if (!mocks.has(path)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
const mock = mocks.get(path)
|
||||
|
||||
@ -20,11 +26,14 @@ export function createModuleMocker() {
|
||||
// TODO: check how the error looks
|
||||
const exports = await getFactoryExports(path)
|
||||
const module = `const module = __vitest_mocker__.get('${path}');`
|
||||
const keys = exports.map((name) => {
|
||||
if (name === 'default')
|
||||
return `export default module['default'];`
|
||||
return `export const ${name} = module['${name}'];`
|
||||
}).join('\n')
|
||||
const keys = exports
|
||||
.map((name) => {
|
||||
if (name === 'default') {
|
||||
return `export default module['default'];`
|
||||
}
|
||||
return `export const ${name} = module['${name}'];`
|
||||
})
|
||||
.join('\n')
|
||||
const text = `${module}\n${keys}`
|
||||
return new Response(text, {
|
||||
headers: {
|
||||
@ -33,8 +42,9 @@ export function createModuleMocker() {
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof mock === 'string')
|
||||
if (typeof mock === 'string') {
|
||||
return Response.redirect(mock)
|
||||
}
|
||||
|
||||
const content = await client.rpc.automock(path)
|
||||
return new Response(content, {
|
||||
@ -49,19 +59,23 @@ export function createModuleMocker() {
|
||||
let startPromise: undefined | Promise<unknown>
|
||||
|
||||
async function init() {
|
||||
if (started)
|
||||
if (started) {
|
||||
return
|
||||
if (startPromise)
|
||||
}
|
||||
if (startPromise) {
|
||||
return startPromise
|
||||
startPromise = worker.start({
|
||||
serviceWorker: {
|
||||
url: '/__virtual_vitest__:mocker-worker.js',
|
||||
},
|
||||
quiet: true,
|
||||
}).finally(() => {
|
||||
started = true
|
||||
startPromise = undefined
|
||||
})
|
||||
}
|
||||
startPromise = worker
|
||||
.start({
|
||||
serviceWorker: {
|
||||
url: '/__virtual_vitest__:mocker-worker.js',
|
||||
},
|
||||
quiet: true,
|
||||
})
|
||||
.finally(() => {
|
||||
started = true
|
||||
startPromise = undefined
|
||||
})
|
||||
await startPromise
|
||||
}
|
||||
|
||||
@ -88,16 +102,19 @@ function getFactoryExports(id: string) {
|
||||
id,
|
||||
})
|
||||
return new Promise<string[]>((resolve, reject) => {
|
||||
channel.addEventListener('message', function onMessage(e: MessageEvent<IframeChannelEvent>) {
|
||||
if (e.data.type === 'mock-factory:response') {
|
||||
resolve(e.data.exports)
|
||||
channel.removeEventListener('message', onMessage)
|
||||
}
|
||||
if (e.data.type === 'mock-factory:error') {
|
||||
reject(e.data.error)
|
||||
channel.removeEventListener('message', onMessage)
|
||||
}
|
||||
})
|
||||
channel.addEventListener(
|
||||
'message',
|
||||
function onMessage(e: MessageEvent<IframeChannelEvent>) {
|
||||
if (e.data.type === 'mock-factory:response') {
|
||||
resolve(e.data.exports)
|
||||
channel.removeEventListener('message', onMessage)
|
||||
}
|
||||
if (e.data.type === 'mock-factory:error') {
|
||||
reject(e.data.error)
|
||||
channel.removeEventListener('message', onMessage)
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -25,8 +25,9 @@ getBrowserState().createTesters = async (files) => {
|
||||
|
||||
function debug(...args: unknown[]) {
|
||||
const debug = getConfig().env.VITEST_BROWSER_DEBUG
|
||||
if (debug && debug !== 'false')
|
||||
if (debug && debug !== 'false') {
|
||||
client.rpc.debug(...args.map(String))
|
||||
}
|
||||
}
|
||||
|
||||
function createIframe(container: HTMLDivElement, file: string) {
|
||||
@ -37,7 +38,12 @@ function createIframe(container: HTMLDivElement, file: string) {
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.setAttribute('loading', 'eager')
|
||||
iframe.setAttribute('src', `${url.pathname}__vitest_test__/__test__/${getBrowserState().contextId}/${encodeURIComponent(file)}`)
|
||||
iframe.setAttribute(
|
||||
'src',
|
||||
`${url.pathname}__vitest_test__/__test__/${
|
||||
getBrowserState().contextId
|
||||
}/${encodeURIComponent(file)}`,
|
||||
)
|
||||
iframe.setAttribute('data-vitest', 'true')
|
||||
|
||||
iframe.style.display = 'block'
|
||||
@ -84,89 +90,106 @@ client.ws.addEventListener('open', async () => {
|
||||
|
||||
const mocker = createModuleMocker()
|
||||
|
||||
channel.addEventListener('message', async (e: MessageEvent<IframeChannelIncomingEvent>): Promise<void> => {
|
||||
debug('channel event', JSON.stringify(e.data))
|
||||
switch (e.data.type) {
|
||||
case 'viewport': {
|
||||
const { width, height, id } = e.data
|
||||
const iframe = iframes.get(id)
|
||||
if (!iframe) {
|
||||
const error = new Error(`Cannot find iframe with id ${id}`)
|
||||
channel.postMessage({ type: 'viewport:fail', id, error: error.message })
|
||||
await client.rpc.onUnhandledError({
|
||||
name: 'Teardown Error',
|
||||
message: error.message,
|
||||
}, 'Teardown Error')
|
||||
return
|
||||
}
|
||||
await setIframeViewport(iframe, width, height)
|
||||
channel.postMessage({ type: 'viewport:done', id })
|
||||
break
|
||||
}
|
||||
case 'done': {
|
||||
const filenames = e.data.filenames
|
||||
filenames.forEach(filename => runningFiles.delete(filename))
|
||||
|
||||
if (!runningFiles.size) {
|
||||
const ui = getUiAPI()
|
||||
// in isolated mode we don't change UI because it will slow down tests,
|
||||
// so we only select it when the run is done
|
||||
if (ui && filenames.length > 1) {
|
||||
const id = generateFileId(filenames[filenames.length - 1])
|
||||
ui.setCurrentFileId(id)
|
||||
channel.addEventListener(
|
||||
'message',
|
||||
async (e: MessageEvent<IframeChannelIncomingEvent>): Promise<void> => {
|
||||
debug('channel event', JSON.stringify(e.data))
|
||||
switch (e.data.type) {
|
||||
case 'viewport': {
|
||||
const { width, height, id } = e.data
|
||||
const iframe = iframes.get(id)
|
||||
if (!iframe) {
|
||||
const error = new Error(`Cannot find iframe with id ${id}`)
|
||||
channel.postMessage({
|
||||
type: 'viewport:fail',
|
||||
id,
|
||||
error: error.message,
|
||||
})
|
||||
await client.rpc.onUnhandledError(
|
||||
{
|
||||
name: 'Teardown Error',
|
||||
message: error.message,
|
||||
},
|
||||
'Teardown Error',
|
||||
)
|
||||
return
|
||||
}
|
||||
await done()
|
||||
await setIframeViewport(iframe, width, height)
|
||||
channel.postMessage({ type: 'viewport:done', id })
|
||||
break
|
||||
}
|
||||
else {
|
||||
// keep the last iframe
|
||||
const iframeId = e.data.id
|
||||
iframes.get(iframeId)?.remove()
|
||||
iframes.delete(iframeId)
|
||||
}
|
||||
break
|
||||
}
|
||||
// error happened at the top level, this should never happen in user code, but it can trigger during development
|
||||
case 'error': {
|
||||
const iframeId = e.data.id
|
||||
iframes.delete(iframeId)
|
||||
await client.rpc.onUnhandledError(e.data.error, e.data.errorType)
|
||||
if (iframeId === ID_ALL)
|
||||
runningFiles.clear()
|
||||
else
|
||||
runningFiles.delete(iframeId)
|
||||
if (!runningFiles.size)
|
||||
await done()
|
||||
break
|
||||
}
|
||||
case 'mock:invalidate':
|
||||
mocker.invalidate()
|
||||
break
|
||||
case 'unmock':
|
||||
await mocker.unmock(e.data)
|
||||
break
|
||||
case 'mock':
|
||||
await mocker.mock(e.data)
|
||||
break
|
||||
case 'mock-factory:error':
|
||||
case 'mock-factory:response':
|
||||
// handled manually
|
||||
break
|
||||
default: {
|
||||
e.data satisfies never
|
||||
case 'done': {
|
||||
const filenames = e.data.filenames
|
||||
filenames.forEach(filename => runningFiles.delete(filename))
|
||||
|
||||
await client.rpc.onUnhandledError({
|
||||
name: 'Unexpected Event',
|
||||
message: `Unexpected event: ${(e.data as any).type}`,
|
||||
}, 'Unexpected Event')
|
||||
await done()
|
||||
if (!runningFiles.size) {
|
||||
const ui = getUiAPI()
|
||||
// in isolated mode we don't change UI because it will slow down tests,
|
||||
// so we only select it when the run is done
|
||||
if (ui && filenames.length > 1) {
|
||||
const id = generateFileId(filenames[filenames.length - 1])
|
||||
ui.setCurrentFileId(id)
|
||||
}
|
||||
await done()
|
||||
}
|
||||
else {
|
||||
// keep the last iframe
|
||||
const iframeId = e.data.id
|
||||
iframes.get(iframeId)?.remove()
|
||||
iframes.delete(iframeId)
|
||||
}
|
||||
break
|
||||
}
|
||||
// error happened at the top level, this should never happen in user code, but it can trigger during development
|
||||
case 'error': {
|
||||
const iframeId = e.data.id
|
||||
iframes.delete(iframeId)
|
||||
await client.rpc.onUnhandledError(e.data.error, e.data.errorType)
|
||||
if (iframeId === ID_ALL) {
|
||||
runningFiles.clear()
|
||||
}
|
||||
else {
|
||||
runningFiles.delete(iframeId)
|
||||
}
|
||||
if (!runningFiles.size) {
|
||||
await done()
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'mock:invalidate':
|
||||
mocker.invalidate()
|
||||
break
|
||||
case 'unmock':
|
||||
await mocker.unmock(e.data)
|
||||
break
|
||||
case 'mock':
|
||||
await mocker.mock(e.data)
|
||||
break
|
||||
case 'mock-factory:error':
|
||||
case 'mock-factory:response':
|
||||
// handled manually
|
||||
break
|
||||
default: {
|
||||
e.data satisfies never
|
||||
|
||||
await client.rpc.onUnhandledError(
|
||||
{
|
||||
name: 'Unexpected Event',
|
||||
message: `Unexpected event: ${(e.data as any).type}`,
|
||||
},
|
||||
'Unexpected Event',
|
||||
)
|
||||
await done()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
// if page was refreshed, there will be no test files
|
||||
// createTesters will be called again when tests are running in the UI
|
||||
if (testFiles.length)
|
||||
if (testFiles.length) {
|
||||
await createTesters(testFiles)
|
||||
}
|
||||
})
|
||||
|
||||
async function createTesters(testFiles: string[]) {
|
||||
@ -186,10 +209,7 @@ async function createTesters(testFiles: string[]) {
|
||||
iframes.clear()
|
||||
|
||||
if (config.isolate === false) {
|
||||
const iframe = createIframe(
|
||||
container,
|
||||
ID_ALL,
|
||||
)
|
||||
const iframe = createIframe(container, ID_ALL)
|
||||
|
||||
await setIframeViewport(iframe, width, height)
|
||||
}
|
||||
@ -197,21 +217,21 @@ async function createTesters(testFiles: string[]) {
|
||||
// otherwise, we need to wait for each iframe to finish before creating the next one
|
||||
// this is the most stable way to run tests in the browser
|
||||
for (const file of testFiles) {
|
||||
const iframe = createIframe(
|
||||
container,
|
||||
file,
|
||||
)
|
||||
const iframe = createIframe(container, file)
|
||||
|
||||
await setIframeViewport(iframe, width, height)
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
channel.addEventListener('message', function handler(e: MessageEvent<IframeChannelEvent>) {
|
||||
// done and error can only be triggered by the previous iframe
|
||||
if (e.data.type === 'done' || e.data.type === 'error') {
|
||||
channel.removeEventListener('message', handler)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
channel.addEventListener(
|
||||
'message',
|
||||
function handler(e: MessageEvent<IframeChannelEvent>) {
|
||||
// done and error can only be triggered by the previous iframe
|
||||
if (e.data.type === 'done' || e.data.type === 'error') {
|
||||
channel.removeEventListener('message', handler)
|
||||
resolve()
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -224,7 +244,11 @@ function generateFileId(file: string) {
|
||||
return generateHash(`${path}${project}`)
|
||||
}
|
||||
|
||||
async function setIframeViewport(iframe: HTMLIFrameElement, width: number, height: number) {
|
||||
async function setIframeViewport(
|
||||
iframe: HTMLIFrameElement,
|
||||
width: number,
|
||||
height: number,
|
||||
) {
|
||||
const ui = getUiAPI()
|
||||
if (ui) {
|
||||
await ui.setIframeViewport(width, height)
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
const moduleCache = new Map()
|
||||
const moduleCache = new Map();
|
||||
|
||||
function wrapModule(module) {
|
||||
if (typeof module === 'function') {
|
||||
if (typeof module === "function") {
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
if (typeof __vitest_mocker__ === 'undefined')
|
||||
return module().then(resolve, reject)
|
||||
if (typeof __vitest_mocker__ === "undefined")
|
||||
return module().then(resolve, reject);
|
||||
__vitest_mocker__.prepare().finally(() => {
|
||||
module().then(resolve, reject)
|
||||
})
|
||||
})
|
||||
moduleCache.set(promise, { promise, evaluated: false })
|
||||
return promise.finally(() => moduleCache.delete(promise))
|
||||
module().then(resolve, reject);
|
||||
});
|
||||
});
|
||||
moduleCache.set(promise, { promise, evaluated: false });
|
||||
return promise.finally(() => moduleCache.delete(promise));
|
||||
}
|
||||
return module
|
||||
return module;
|
||||
}
|
||||
|
||||
window.__vitest_browser_runner__ = {
|
||||
@ -23,25 +23,24 @@ window.__vitest_browser_runner__ = {
|
||||
files: { __VITEST_FILES__ },
|
||||
type: { __VITEST_TYPE__ },
|
||||
contextId: { __VITEST_CONTEXT_ID__ },
|
||||
}
|
||||
};
|
||||
|
||||
const config = __vitest_browser_runner__.config
|
||||
const config = __vitest_browser_runner__.config;
|
||||
|
||||
if (config.testNamePattern)
|
||||
config.testNamePattern = parseRegexp(config.testNamePattern)
|
||||
config.testNamePattern = parseRegexp(config.testNamePattern);
|
||||
|
||||
function parseRegexp(input) {
|
||||
// Parse input
|
||||
const m = input.match(/(\/?)(.+)\1([a-z]*)/i)
|
||||
const m = input.match(/(\/?)(.+)\1([a-z]*)/i);
|
||||
|
||||
// match nothing
|
||||
if (!m)
|
||||
return /$^/
|
||||
if (!m) return /$^/;
|
||||
|
||||
// Invalid flags
|
||||
if (m[3] && !/^(?!.*?(.).*?\1)[gmixXsuUAJ]+$/.test(m[3]))
|
||||
return RegExp(input)
|
||||
return RegExp(input);
|
||||
|
||||
// Create the regular expression
|
||||
return new RegExp(m[2], m[3])
|
||||
return new RegExp(m[2], m[3]);
|
||||
}
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
import type {
|
||||
getSafeTimers,
|
||||
} from '@vitest/utils'
|
||||
import type { getSafeTimers } from '@vitest/utils'
|
||||
import { importId } from './utils'
|
||||
import type { VitestBrowserClient } from './client'
|
||||
|
||||
const { get } = Reflect
|
||||
|
||||
function withSafeTimers(getTimers: typeof getSafeTimers, fn: () => void) {
|
||||
const { setTimeout, clearTimeout, setImmediate, clearImmediate } = getTimers()
|
||||
const { setTimeout, clearTimeout, setImmediate, clearImmediate }
|
||||
= getTimers()
|
||||
|
||||
const currentSetTimeout = globalThis.setTimeout
|
||||
const currentClearTimeout = globalThis.clearTimeout
|
||||
@ -34,28 +33,34 @@ function withSafeTimers(getTimers: typeof getSafeTimers, fn: () => void) {
|
||||
const promises = new Set<Promise<unknown>>()
|
||||
|
||||
export async function rpcDone() {
|
||||
if (!promises.size)
|
||||
if (!promises.size) {
|
||||
return
|
||||
}
|
||||
const awaitable = Array.from(promises)
|
||||
return Promise.all(awaitable)
|
||||
}
|
||||
|
||||
export function createSafeRpc(client: VitestBrowserClient, getTimers: () => any): VitestBrowserClient['rpc'] {
|
||||
export function createSafeRpc(
|
||||
client: VitestBrowserClient,
|
||||
getTimers: () => any,
|
||||
): VitestBrowserClient['rpc'] {
|
||||
return new Proxy(client.rpc, {
|
||||
get(target, p, handler) {
|
||||
if (p === 'then')
|
||||
if (p === 'then') {
|
||||
return
|
||||
}
|
||||
const sendCall = get(target, p, handler)
|
||||
const safeSendCall = (...args: any[]) => withSafeTimers(getTimers, async () => {
|
||||
const result = sendCall(...args)
|
||||
promises.add(result)
|
||||
try {
|
||||
return await result
|
||||
}
|
||||
finally {
|
||||
promises.delete(result)
|
||||
}
|
||||
})
|
||||
const safeSendCall = (...args: any[]) =>
|
||||
withSafeTimers(getTimers, async () => {
|
||||
const result = sendCall(...args)
|
||||
promises.add(result)
|
||||
try {
|
||||
return await result
|
||||
}
|
||||
finally {
|
||||
promises.delete(result)
|
||||
}
|
||||
})
|
||||
safeSendCall.asEvent = sendCall.asEvent
|
||||
return safeSendCall
|
||||
},
|
||||
@ -64,7 +69,9 @@ export function createSafeRpc(client: VitestBrowserClient, getTimers: () => any)
|
||||
|
||||
export async function loadSafeRpc(client: VitestBrowserClient) {
|
||||
// if importing /@id/ failed, we reload the page waiting until Vite prebundles it
|
||||
const { getSafeTimers } = await importId('vitest/utils') as typeof import('vitest/utils')
|
||||
const { getSafeTimers } = (await importId(
|
||||
'vitest/utils',
|
||||
)) as typeof import('vitest/utils')
|
||||
return createSafeRpc(client, getSafeTimers)
|
||||
}
|
||||
|
||||
|
||||
@ -10,18 +10,21 @@ interface BrowserRunnerOptions {
|
||||
config: ResolvedConfig
|
||||
}
|
||||
|
||||
export const browserHashMap = new Map<string, [test: boolean, timstamp: string]>()
|
||||
export const browserHashMap = new Map<
|
||||
string,
|
||||
[test: boolean, timstamp: string]
|
||||
>()
|
||||
|
||||
interface CoverageHandler {
|
||||
takeCoverage: () => Promise<unknown>
|
||||
}
|
||||
|
||||
export function createBrowserRunner(
|
||||
runnerClass: { new(config: ResolvedConfig): VitestRunner },
|
||||
runnerClass: { new (config: ResolvedConfig): VitestRunner },
|
||||
mocker: VitestBrowserClientMocker,
|
||||
state: WorkerGlobalState,
|
||||
coverageModule: CoverageHandler | null,
|
||||
): { new(options: BrowserRunnerOptions): VitestRunner } {
|
||||
): { new (options: BrowserRunnerOptions): VitestRunner } {
|
||||
return class BrowserTestRunner extends runnerClass implements VitestRunner {
|
||||
public config: ResolvedConfig
|
||||
hashMap = browserHashMap
|
||||
@ -101,9 +104,14 @@ export function createBrowserRunner(
|
||||
|
||||
let cachedRunner: VitestRunner | null = null
|
||||
|
||||
export async function initiateRunner(state: WorkerGlobalState, mocker: VitestBrowserClientMocker, config: ResolvedConfig) {
|
||||
if (cachedRunner)
|
||||
export async function initiateRunner(
|
||||
state: WorkerGlobalState,
|
||||
mocker: VitestBrowserClientMocker,
|
||||
config: ResolvedConfig,
|
||||
) {
|
||||
if (cachedRunner) {
|
||||
return cachedRunner
|
||||
}
|
||||
const [
|
||||
{ VitestTestRunner, NodeBenchmarkRunner },
|
||||
{ takeCoverageInsideWorker, loadDiffConfig, loadSnapshotSerializers },
|
||||
@ -111,12 +119,16 @@ export async function initiateRunner(state: WorkerGlobalState, mocker: VitestBro
|
||||
importId('vitest/runners') as Promise<typeof import('vitest/runners')>,
|
||||
importId('vitest/browser') as Promise<typeof import('vitest/browser')>,
|
||||
])
|
||||
const runnerClass = config.mode === 'test' ? VitestTestRunner : NodeBenchmarkRunner
|
||||
const runnerClass
|
||||
= config.mode === 'test' ? VitestTestRunner : NodeBenchmarkRunner
|
||||
const BrowserRunner = createBrowserRunner(runnerClass, mocker, state, {
|
||||
takeCoverage: () => takeCoverageInsideWorker(config.coverage, { executeId: importId }),
|
||||
takeCoverage: () =>
|
||||
takeCoverageInsideWorker(config.coverage, { executeId: importId }),
|
||||
})
|
||||
if (!config.snapshotOptions.snapshotEnvironment)
|
||||
config.snapshotOptions.snapshotEnvironment = new VitestBrowserSnapshotEnvironment()
|
||||
if (!config.snapshotOptions.snapshotEnvironment) {
|
||||
config.snapshotOptions.snapshotEnvironment
|
||||
= new VitestBrowserSnapshotEnvironment()
|
||||
}
|
||||
const runner = new BrowserRunner({
|
||||
config,
|
||||
})
|
||||
@ -131,22 +143,27 @@ export async function initiateRunner(state: WorkerGlobalState, mocker: VitestBro
|
||||
}
|
||||
|
||||
async function updateFilesLocations(files: File[]) {
|
||||
const { loadSourceMapUtils } = await importId('vitest/utils') as typeof import('vitest/utils')
|
||||
const { loadSourceMapUtils } = (await importId(
|
||||
'vitest/utils',
|
||||
)) as typeof import('vitest/utils')
|
||||
const { TraceMap, originalPositionFor } = await loadSourceMapUtils()
|
||||
|
||||
const promises = files.map(async (file) => {
|
||||
const result = await rpc().getBrowserFileSourceMap(file.filepath)
|
||||
if (!result)
|
||||
if (!result) {
|
||||
return null
|
||||
}
|
||||
const traceMap = new TraceMap(result as any)
|
||||
function updateLocation(task: Task) {
|
||||
if (task.location) {
|
||||
const { line, column } = originalPositionFor(traceMap, task.location)
|
||||
if (line != null && column != null)
|
||||
if (line != null && column != null) {
|
||||
task.location = { line, column: task.each ? column : column + 1 }
|
||||
}
|
||||
}
|
||||
if ('tasks' in task)
|
||||
if ('tasks' in task) {
|
||||
task.tasks.forEach(updateLocation)
|
||||
}
|
||||
}
|
||||
file.tasks.forEach(updateLocation)
|
||||
return null
|
||||
|
||||
@ -19,7 +19,14 @@
|
||||
<script>{__VITEST_INJECTOR__}</script>
|
||||
{__VITEST_SCRIPTS__}
|
||||
</head>
|
||||
<body style="width: 100%; height: 100%; transform: scale(1); transform-origin: left top;">
|
||||
<body
|
||||
style="
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: scale(1);
|
||||
transform-origin: left top;
|
||||
"
|
||||
>
|
||||
<script type="module" src="/tester.ts"></script>
|
||||
{__VITEST_APPEND__}
|
||||
</body>
|
||||
|
||||
@ -6,7 +6,11 @@ import { browserHashMap, initiateRunner } from './runner'
|
||||
import { getBrowserState, getConfig, importId } from './utils'
|
||||
import { loadSafeRpc } from './rpc'
|
||||
import { VitestBrowserClientMocker } from './mocker'
|
||||
import { registerUnexpectedErrors, registerUnhandledErrors, serializeError } from './unhandled'
|
||||
import {
|
||||
registerUnexpectedErrors,
|
||||
registerUnhandledErrors,
|
||||
serializeError,
|
||||
} from './unhandled'
|
||||
|
||||
const stopErrorHandler = registerUnhandledErrors()
|
||||
|
||||
@ -15,26 +19,44 @@ const reloadStart = url.searchParams.get('__reloadStart')
|
||||
|
||||
function debug(...args: unknown[]) {
|
||||
const debug = getConfig().env.VITEST_BROWSER_DEBUG
|
||||
if (debug && debug !== 'false')
|
||||
if (debug && debug !== 'false') {
|
||||
client.rpc.debug(...args.map(String))
|
||||
}
|
||||
}
|
||||
|
||||
async function tryCall<T>(fn: () => Promise<T>): Promise<T | false | undefined> {
|
||||
async function tryCall<T>(
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T | false | undefined> {
|
||||
try {
|
||||
return await fn()
|
||||
}
|
||||
catch (err: any) {
|
||||
const now = Date.now()
|
||||
// try for 30 seconds
|
||||
const canTry = !reloadStart || (now - Number(reloadStart) < 30_000)
|
||||
const canTry = !reloadStart || now - Number(reloadStart) < 30_000
|
||||
const errorStack = (() => {
|
||||
if (!err)
|
||||
if (!err) {
|
||||
return null
|
||||
return err.stack?.includes(err.message) ? err.stack : `${err.message}\n${err.stack}`
|
||||
}
|
||||
return err.stack?.includes(err.message)
|
||||
? err.stack
|
||||
: `${err.message}\n${err.stack}`
|
||||
})()
|
||||
debug('failed to resolve runner', 'trying again:', canTry, 'time is', now, 'reloadStart is', reloadStart, ':\n', errorStack)
|
||||
debug(
|
||||
'failed to resolve runner',
|
||||
'trying again:',
|
||||
canTry,
|
||||
'time is',
|
||||
now,
|
||||
'reloadStart is',
|
||||
reloadStart,
|
||||
':\n',
|
||||
errorStack,
|
||||
)
|
||||
if (!canTry) {
|
||||
const error = serializeError(new Error('Vitest failed to load its runner after 30 seconds.'))
|
||||
const error = serializeError(
|
||||
new Error('Vitest failed to load its runner after 30 seconds.'),
|
||||
)
|
||||
error.cause = serializeError(err)
|
||||
|
||||
await client.rpc.onUnhandledError(error, 'Preload Error')
|
||||
@ -114,14 +136,17 @@ async function prepareTestEnvironment(files: string[]) {
|
||||
const version = url.searchParams.get('browserv') || ''
|
||||
files.forEach((filename) => {
|
||||
const currentVersion = browserHashMap.get(filename)
|
||||
if (!currentVersion || currentVersion[1] !== version)
|
||||
if (!currentVersion || currentVersion[1] !== version) {
|
||||
browserHashMap.set(filename, [true, version])
|
||||
}
|
||||
})
|
||||
|
||||
const [runner, { startTests, setupCommonEnv, SpyModule }] = await Promise.all([
|
||||
initiateRunner(state, mocker, config),
|
||||
importId('vitest/browser') as Promise<typeof import('vitest/browser')>,
|
||||
])
|
||||
const [runner, { startTests, setupCommonEnv, SpyModule }] = await Promise.all(
|
||||
[
|
||||
initiateRunner(state, mocker, config),
|
||||
importId('vitest/browser') as Promise<typeof import('vitest/browser')>,
|
||||
],
|
||||
)
|
||||
|
||||
mocker.setSpyModule(SpyModule)
|
||||
mocker.setupWorker()
|
||||
@ -155,7 +180,10 @@ async function runTests(files: string[]) {
|
||||
|
||||
debug('client is connected to ws server')
|
||||
|
||||
let preparedData: Awaited<ReturnType<typeof prepareTestEnvironment>> | undefined | false
|
||||
let preparedData:
|
||||
| Awaited<ReturnType<typeof prepareTestEnvironment>>
|
||||
| undefined
|
||||
| false
|
||||
|
||||
// if importing /@id/ failed, we reload the page waiting until Vite prebundles it
|
||||
try {
|
||||
@ -191,8 +219,9 @@ async function runTests(files: string[]) {
|
||||
|
||||
try {
|
||||
await setupCommonEnv(config)
|
||||
for (const file of files)
|
||||
for (const file of files) {
|
||||
await startTests([file], runner)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
state.environmentTeardownRun = true
|
||||
|
||||
@ -31,22 +31,30 @@ async function defaultErrorReport(type: string, unhandledError: any) {
|
||||
function catchWindowErrors(cb: (e: ErrorEvent) => void) {
|
||||
let userErrorListenerCount = 0
|
||||
function throwUnhandlerError(e: ErrorEvent) {
|
||||
if (userErrorListenerCount === 0 && e.error != null)
|
||||
if (userErrorListenerCount === 0 && e.error != null) {
|
||||
cb(e)
|
||||
else
|
||||
}
|
||||
else {
|
||||
console.error(e.error)
|
||||
}
|
||||
}
|
||||
const addEventListener = window.addEventListener.bind(window)
|
||||
const removeEventListener = window.removeEventListener.bind(window)
|
||||
window.addEventListener('error', throwUnhandlerError)
|
||||
window.addEventListener = function (...args: Parameters<typeof addEventListener>) {
|
||||
if (args[0] === 'error')
|
||||
window.addEventListener = function (
|
||||
...args: Parameters<typeof addEventListener>
|
||||
) {
|
||||
if (args[0] === 'error') {
|
||||
userErrorListenerCount++
|
||||
}
|
||||
return addEventListener.apply(this, args)
|
||||
}
|
||||
window.removeEventListener = function (...args: Parameters<typeof removeEventListener>) {
|
||||
if (args[0] === 'error' && userErrorListenerCount)
|
||||
window.removeEventListener = function (
|
||||
...args: Parameters<typeof removeEventListener>
|
||||
) {
|
||||
if (args[0] === 'error' && userErrorListenerCount) {
|
||||
userErrorListenerCount--
|
||||
}
|
||||
return removeEventListener.apply(this, args)
|
||||
}
|
||||
return function clearErrorHandlers() {
|
||||
@ -55,8 +63,11 @@ function catchWindowErrors(cb: (e: ErrorEvent) => void) {
|
||||
}
|
||||
|
||||
export function registerUnhandledErrors() {
|
||||
const stopErrorHandler = catchWindowErrors(e => defaultErrorReport('Error', e.error))
|
||||
const stopRejectionHandler = on('unhandledrejection', e => defaultErrorReport('Unhandled Rejection', e.reason))
|
||||
const stopErrorHandler = catchWindowErrors(e =>
|
||||
defaultErrorReport('Error', e.error),
|
||||
)
|
||||
const stopRejectionHandler = on('unhandledrejection', e =>
|
||||
defaultErrorReport('Unhandled Rejection', e.reason))
|
||||
return () => {
|
||||
stopErrorHandler()
|
||||
stopRejectionHandler()
|
||||
@ -64,12 +75,21 @@ export function registerUnhandledErrors() {
|
||||
}
|
||||
|
||||
export function registerUnexpectedErrors(rpc: typeof client.rpc) {
|
||||
catchWindowErrors(event => reportUnexpectedError(rpc, 'Error', event.error))
|
||||
on('unhandledrejection', event => reportUnexpectedError(rpc, 'Unhandled Rejection', event.reason))
|
||||
catchWindowErrors(event =>
|
||||
reportUnexpectedError(rpc, 'Error', event.error),
|
||||
)
|
||||
on('unhandledrejection', event =>
|
||||
reportUnexpectedError(rpc, 'Unhandled Rejection', event.reason))
|
||||
}
|
||||
|
||||
async function reportUnexpectedError(rpc: typeof client.rpc, type: string, error: any) {
|
||||
const { processError } = await importId('vitest/browser') as typeof import('vitest/browser')
|
||||
async function reportUnexpectedError(
|
||||
rpc: typeof client.rpc,
|
||||
type: string,
|
||||
error: any,
|
||||
) {
|
||||
const { processError } = (await importId(
|
||||
'vitest/browser',
|
||||
)) as typeof import('vitest/browser')
|
||||
const processedError = processError(error)
|
||||
await rpc.onUnhandledError(processedError, type)
|
||||
}
|
||||
|
||||
@ -27,27 +27,35 @@ export default defineConfig({
|
||||
name: 'virtual:msw',
|
||||
enforce: 'pre',
|
||||
resolveId(id) {
|
||||
if (id.startsWith('msw'))
|
||||
if (id.startsWith('msw')) {
|
||||
return `/__virtual_vitest__:${id}`
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'copy-ui-plugin',
|
||||
/* eslint-disable no-console */
|
||||
closeBundle: async () => {
|
||||
const root = resolve(fileURLToPath(import.meta.url), '../../../../../packages')
|
||||
const root = resolve(
|
||||
fileURLToPath(import.meta.url),
|
||||
'../../../../../packages',
|
||||
)
|
||||
|
||||
const ui = resolve(root, 'ui/dist/client')
|
||||
const browser = resolve(root, 'browser/dist/client/__vitest__/')
|
||||
|
||||
const timeout = setTimeout(() => console.log('[copy-ui-plugin] Waiting for UI to be built...'), 1000)
|
||||
const timeout = setTimeout(
|
||||
() => console.log('[copy-ui-plugin] Waiting for UI to be built...'),
|
||||
1000,
|
||||
)
|
||||
await waitFor(() => fs.existsSync(ui))
|
||||
clearTimeout(timeout)
|
||||
|
||||
const files = fg.sync('**/*', { cwd: ui })
|
||||
|
||||
if (fs.existsSync(browser))
|
||||
if (fs.existsSync(browser)) {
|
||||
fs.rmSync(browser, { recursive: true })
|
||||
}
|
||||
|
||||
fs.mkdirSync(browser, { recursive: true })
|
||||
fs.mkdirSync(resolve(browser, 'assets'))
|
||||
@ -63,11 +71,13 @@ export default defineConfig({
|
||||
})
|
||||
|
||||
async function waitFor(method: () => boolean, retries = 100): Promise<void> {
|
||||
if (method())
|
||||
if (method()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (retries === 0)
|
||||
if (retries === 0) {
|
||||
throw new Error('Timeout in waitFor')
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
|
||||
@ -1,4 +1,14 @@
|
||||
import type { Declaration, ExportDefaultDeclaration, ExportNamedDeclaration, Expression, Identifier, Literal, Pattern, Positioned, Program } from '@vitest/utils/ast'
|
||||
import type {
|
||||
Declaration,
|
||||
ExportDefaultDeclaration,
|
||||
ExportNamedDeclaration,
|
||||
Expression,
|
||||
Identifier,
|
||||
Literal,
|
||||
Pattern,
|
||||
Positioned,
|
||||
Program,
|
||||
} from '@vitest/utils/ast'
|
||||
import MagicString from 'magic-string'
|
||||
|
||||
// TODO: better source map replacement
|
||||
@ -28,21 +38,25 @@ export function automockModule(code: string, parse: (code: string) => Program) {
|
||||
// export const [test, ...rest] = [1, 2, 3]
|
||||
else if (expression.type === 'ArrayPattern') {
|
||||
expression.elements.forEach((element) => {
|
||||
if (!element)
|
||||
if (!element) {
|
||||
return
|
||||
}
|
||||
traversePattern(element)
|
||||
})
|
||||
}
|
||||
else if (expression.type === 'ObjectPattern') {
|
||||
expression.properties.forEach((property) => {
|
||||
// export const { ...rest } = {}
|
||||
if (property.type === 'RestElement')
|
||||
if (property.type === 'RestElement') {
|
||||
traversePattern(property)
|
||||
}
|
||||
// export const { test, test2: alias } = {}
|
||||
else if (property.type === 'Property')
|
||||
else if (property.type === 'Property') {
|
||||
traversePattern(property.value)
|
||||
else
|
||||
}
|
||||
else {
|
||||
property satisfies never
|
||||
}
|
||||
})
|
||||
}
|
||||
else if (expression.type === 'RestElement') {
|
||||
@ -51,12 +65,16 @@ export function automockModule(code: string, parse: (code: string) => Program) {
|
||||
// const [name[1], name[2]] = []
|
||||
// cannot be used in export
|
||||
else if (expression.type === 'AssignmentPattern') {
|
||||
throw new Error(`AssignmentPattern is not supported. Please open a new bug report.`)
|
||||
throw new Error(
|
||||
`AssignmentPattern is not supported. Please open a new bug report.`,
|
||||
)
|
||||
}
|
||||
// const test = thing.func()
|
||||
// cannot be used in export
|
||||
else if (expression.type === 'MemberExpression') {
|
||||
throw new Error(`MemberExpression is not supported. Please open a new bug report.`)
|
||||
throw new Error(
|
||||
`MemberExpression is not supported. Please open a new bug report.`,
|
||||
)
|
||||
}
|
||||
else {
|
||||
expression satisfies never
|
||||
@ -89,9 +107,7 @@ export function automockModule(code: string, parse: (code: string) => Program) {
|
||||
const exported = specifier.exported as Literal | Identifier
|
||||
|
||||
allSpecifiers.push({
|
||||
alias: exported.type === 'Literal'
|
||||
? exported.raw!
|
||||
: exported.name,
|
||||
alias: exported.type === 'Literal' ? exported.raw! : exported.name,
|
||||
name: specifier.local.name,
|
||||
})
|
||||
})
|
||||
@ -106,13 +122,13 @@ export function automockModule(code: string, parse: (code: string) => Program) {
|
||||
importNames.push([specifier.local.name, importedName])
|
||||
allSpecifiers.push({
|
||||
name: importedName,
|
||||
alias: exported.type === 'Literal'
|
||||
? exported.raw!
|
||||
: exported.name,
|
||||
alias: exported.type === 'Literal' ? exported.raw! : exported.name,
|
||||
})
|
||||
})
|
||||
|
||||
const importString = `import { ${importNames.map(([name, alias]) => `${name} as ${alias}`).join(', ')} } from '${source.value}'`
|
||||
const importString = `import { ${importNames
|
||||
.map(([name, alias]) => `${name} as ${alias}`)
|
||||
.join(', ')} } from '${source.value}'`
|
||||
|
||||
m.overwrite(node.start, node.end, importString)
|
||||
}
|
||||
@ -131,13 +147,17 @@ const __vitest_es_current_module__ = {
|
||||
}
|
||||
const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__)
|
||||
`
|
||||
const assigning = allSpecifiers.map(({ name }, index) => {
|
||||
return `const __vitest_mocked_${index}__ = __vitest_mocked_module__["${name}"]`
|
||||
}).join('\n')
|
||||
const assigning = allSpecifiers
|
||||
.map(({ name }, index) => {
|
||||
return `const __vitest_mocked_${index}__ = __vitest_mocked_module__["${name}"]`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
const redeclarations = allSpecifiers.map(({ name, alias }, index) => {
|
||||
return ` __vitest_mocked_${index}__ as ${alias || name},`
|
||||
}).join('\n')
|
||||
const redeclarations = allSpecifiers
|
||||
.map(({ name, alias }, index) => {
|
||||
return ` __vitest_mocked_${index}__ as ${alias || name},`
|
||||
})
|
||||
.join('\n')
|
||||
const specifiersExports = `
|
||||
export {
|
||||
${redeclarations}
|
||||
|
||||
@ -5,29 +5,43 @@ import type { BrowserCommand, WorkspaceProject } from 'vitest/node'
|
||||
import type { BrowserCommands } from '../../../context'
|
||||
|
||||
function assertFileAccess(path: string, project: WorkspaceProject) {
|
||||
if (!isFileServingAllowed(path, project.server) && !isFileServingAllowed(path, project.ctx.server))
|
||||
throw new Error(`Access denied to "${path}". See Vite config documentation for "server.fs": https://vitejs.dev/config/server-options.html#server-fs-strict.`)
|
||||
if (
|
||||
!isFileServingAllowed(path, project.server)
|
||||
&& !isFileServingAllowed(path, project.ctx.server)
|
||||
) {
|
||||
throw new Error(
|
||||
`Access denied to "${path}". See Vite config documentation for "server.fs": https://vitejs.dev/config/server-options.html#server-fs-strict.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const readFile: BrowserCommand<Parameters<BrowserCommands['readFile']>> = async ({ project, testPath = process.cwd() }, path, options = {}) => {
|
||||
export const readFile: BrowserCommand<
|
||||
Parameters<BrowserCommands['readFile']>
|
||||
> = async ({ project, testPath = process.cwd() }, path, options = {}) => {
|
||||
const filepath = resolve(dirname(testPath), path)
|
||||
assertFileAccess(filepath, project)
|
||||
// never return a Buffer
|
||||
if (typeof options === 'object' && !options.encoding)
|
||||
if (typeof options === 'object' && !options.encoding) {
|
||||
options.encoding = 'utf-8'
|
||||
}
|
||||
return fsp.readFile(filepath, options)
|
||||
}
|
||||
|
||||
export const writeFile: BrowserCommand<Parameters<BrowserCommands['writeFile']>> = async ({ project, testPath = process.cwd() }, path, data, options) => {
|
||||
export const writeFile: BrowserCommand<
|
||||
Parameters<BrowserCommands['writeFile']>
|
||||
> = async ({ project, testPath = process.cwd() }, path, data, options) => {
|
||||
const filepath = resolve(dirname(testPath), path)
|
||||
assertFileAccess(filepath, project)
|
||||
const dir = dirname(filepath)
|
||||
if (!fs.existsSync(dir))
|
||||
if (!fs.existsSync(dir)) {
|
||||
await fsp.mkdir(dir, { recursive: true })
|
||||
}
|
||||
await fsp.writeFile(filepath, data, options)
|
||||
}
|
||||
|
||||
export const removeFile: BrowserCommand<Parameters<BrowserCommands['removeFile']>> = async ({ project, testPath = process.cwd() }, path) => {
|
||||
export const removeFile: BrowserCommand<
|
||||
Parameters<BrowserCommands['removeFile']>
|
||||
> = async ({ project, testPath = process.cwd() }, path) => {
|
||||
const filepath = resolve(dirname(testPath), path)
|
||||
assertFileAccess(filepath, project)
|
||||
await fsp.rm(filepath)
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
import { click } from './click'
|
||||
import {
|
||||
readFile,
|
||||
removeFile,
|
||||
writeFile,
|
||||
} from './fs'
|
||||
import { readFile, removeFile, writeFile } from './fs'
|
||||
import { sendKeys } from './keyboard'
|
||||
import { screenshot } from './screenshot'
|
||||
|
||||
|
||||
@ -19,13 +19,16 @@ function isObject(payload: unknown): payload is Record<string, unknown> {
|
||||
function isSendKeysPayload(payload: unknown): boolean {
|
||||
const validOptions = ['type', 'press', 'down', 'up']
|
||||
|
||||
if (!isObject(payload))
|
||||
if (!isObject(payload)) {
|
||||
throw new Error('You must provide a `SendKeysPayload` object')
|
||||
}
|
||||
|
||||
const numberOfValidOptions = Object.keys(payload).filter(key =>
|
||||
validOptions.includes(key),
|
||||
).length
|
||||
const unknownOptions = Object.keys(payload).filter(key => !validOptions.includes(key))
|
||||
const unknownOptions = Object.keys(payload).filter(
|
||||
key => !validOptions.includes(key),
|
||||
)
|
||||
|
||||
if (numberOfValidOptions > 1) {
|
||||
throw new Error(
|
||||
@ -41,8 +44,11 @@ function isSendKeysPayload(payload: unknown): boolean {
|
||||
)}.`,
|
||||
)
|
||||
}
|
||||
if (unknownOptions.length > 0)
|
||||
throw new Error(`Unknown options \`${unknownOptions.join(', ')}\` present.`)
|
||||
if (unknownOptions.length > 0) {
|
||||
throw new Error(
|
||||
`Unknown options \`${unknownOptions.join(', ')}\` present.`,
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@ -63,31 +69,43 @@ function isUpPayload(payload: SendKeysPayload): payload is UpPayload {
|
||||
return 'up' in payload
|
||||
}
|
||||
|
||||
export const sendKeys: BrowserCommand<Parameters<BrowserCommands['sendKeys']>> = async ({ provider, contextId }, payload) => {
|
||||
if (!isSendKeysPayload(payload) || !payload)
|
||||
export const sendKeys: BrowserCommand<
|
||||
Parameters<BrowserCommands['sendKeys']>
|
||||
> = async ({ provider, contextId }, payload) => {
|
||||
if (!isSendKeysPayload(payload) || !payload) {
|
||||
throw new Error('You must provide a `SendKeysPayload` object')
|
||||
}
|
||||
|
||||
if (provider instanceof PlaywrightBrowserProvider) {
|
||||
const page = provider.getPage(contextId)
|
||||
if (isTypePayload(payload))
|
||||
if (isTypePayload(payload)) {
|
||||
await page.keyboard.type(payload.type)
|
||||
else if (isPressPayload(payload))
|
||||
}
|
||||
else if (isPressPayload(payload)) {
|
||||
await page.keyboard.press(payload.press)
|
||||
else if (isDownPayload(payload))
|
||||
}
|
||||
else if (isDownPayload(payload)) {
|
||||
await page.keyboard.down(payload.down)
|
||||
else if (isUpPayload(payload))
|
||||
}
|
||||
else if (isUpPayload(payload)) {
|
||||
await page.keyboard.up(payload.up)
|
||||
}
|
||||
}
|
||||
else if (provider instanceof WebdriverBrowserProvider) {
|
||||
const browser = provider.browser!
|
||||
if (isTypePayload(payload))
|
||||
if (isTypePayload(payload)) {
|
||||
await browser.keys(payload.type.split(''))
|
||||
else if (isPressPayload(payload))
|
||||
}
|
||||
else if (isPressPayload(payload)) {
|
||||
await browser.keys([payload.press])
|
||||
else
|
||||
}
|
||||
else {
|
||||
throw new Error('Only "press" and "type" are supported by webdriverio.')
|
||||
}
|
||||
}
|
||||
else {
|
||||
throw new TypeError(`"sendKeys" is not supported for ${provider.name} browser provider.`)
|
||||
throw new TypeError(
|
||||
`"sendKeys" is not supported for ${provider.name} browser provider.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,11 +8,20 @@ import { PlaywrightBrowserProvider } from '../providers/playwright'
|
||||
import { WebdriverBrowserProvider } from '../providers/webdriver'
|
||||
|
||||
// TODO: expose provider specific options in types
|
||||
export const screenshot: BrowserCommand<[string, ScreenshotOptions]> = async (context, name: string, options = {}) => {
|
||||
if (!context.testPath)
|
||||
export const screenshot: BrowserCommand<[string, ScreenshotOptions]> = async (
|
||||
context,
|
||||
name: string,
|
||||
options = {},
|
||||
) => {
|
||||
if (!context.testPath) {
|
||||
throw new Error(`Cannot take a screenshot without a test path`)
|
||||
}
|
||||
|
||||
const path = resolveScreenshotPath(context.testPath, name, context.project.config)
|
||||
const path = resolveScreenshotPath(
|
||||
context.testPath,
|
||||
name,
|
||||
context.project.config,
|
||||
)
|
||||
const savePath = normalize(path)
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
|
||||
@ -42,10 +51,16 @@ export const screenshot: BrowserCommand<[string, ScreenshotOptions]> = async (co
|
||||
return path
|
||||
}
|
||||
|
||||
throw new Error(`Provider "${context.provider.name}" does not support screenshots`)
|
||||
throw new Error(
|
||||
`Provider "${context.provider.name}" does not support screenshots`,
|
||||
)
|
||||
}
|
||||
|
||||
function resolveScreenshotPath(testPath: string, name: string, config: ResolvedConfig) {
|
||||
function resolveScreenshotPath(
|
||||
testPath: string,
|
||||
name: string,
|
||||
config: ResolvedConfig,
|
||||
) {
|
||||
const dir = dirname(testPath)
|
||||
const base = basename(testPath)
|
||||
if (config.browser.screenshotDirectory) {
|
||||
|
||||
@ -6,7 +6,7 @@ export type UserEventCommand<T extends (...args: any) => any> = BrowserCommand<
|
||||
|
||||
type ConvertElementToLocator<T> = T extends Element ? string : T
|
||||
type ConvertUserEventParameters<T extends unknown[]> = {
|
||||
[K in keyof T]: ConvertElementToLocator<T[K]>
|
||||
[K in keyof T]: ConvertElementToLocator<T[K]>;
|
||||
}
|
||||
|
||||
export function defineBrowserCommand<T extends unknown[]>(
|
||||
|
||||
@ -3,7 +3,11 @@ import type { PluginContext } from 'rollup'
|
||||
import { esmWalker } from '@vitest/utils/ast'
|
||||
import type { Expression, Positioned } from '@vitest/utils/ast'
|
||||
|
||||
export function injectDynamicImport(code: string, id: string, parse: PluginContext['parse']) {
|
||||
export function injectDynamicImport(
|
||||
code: string,
|
||||
id: string,
|
||||
parse: PluginContext['parse'],
|
||||
) {
|
||||
const s = new MagicString(code)
|
||||
|
||||
let ast: any
|
||||
@ -23,7 +27,11 @@ export function injectDynamicImport(code: string, id: string, parse: PluginConte
|
||||
},
|
||||
onDynamicImport(node) {
|
||||
const replace = '__vitest_browser_runner__.wrapModule(() => import('
|
||||
s.overwrite(node.start, (node.source as Positioned<Expression>).start, replace)
|
||||
s.overwrite(
|
||||
node.start,
|
||||
(node.source as Positioned<Expression>).start,
|
||||
replace,
|
||||
)
|
||||
s.overwrite(node.end - 1, node.end, '))')
|
||||
},
|
||||
})
|
||||
|
||||
@ -31,39 +31,54 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
|
||||
}
|
||||
},
|
||||
async configureServer(server) {
|
||||
const testerHtml = readFile(resolve(distRoot, 'client/tester.html'), 'utf8')
|
||||
const testerHtml = readFile(
|
||||
resolve(distRoot, 'client/tester.html'),
|
||||
'utf8',
|
||||
)
|
||||
const orchestratorHtml = project.config.browser.ui
|
||||
? readFile(resolve(distRoot, 'client/__vitest__/index.html'), 'utf8')
|
||||
: readFile(resolve(distRoot, 'client/orchestrator.html'), 'utf8')
|
||||
const injectorJs = readFile(resolve(distRoot, 'client/esm-client-injector.js'), 'utf8')
|
||||
const injectorJs = readFile(
|
||||
resolve(distRoot, 'client/esm-client-injector.js'),
|
||||
'utf8',
|
||||
)
|
||||
const manifest = (async () => {
|
||||
return JSON.parse(await readFile(`${distRoot}/client/.vite/manifest.json`, 'utf8'))
|
||||
return JSON.parse(
|
||||
await readFile(`${distRoot}/client/.vite/manifest.json`, 'utf8'),
|
||||
)
|
||||
})()
|
||||
const favicon = `${base}favicon.svg`
|
||||
const testerPrefix = `${base}__vitest_test__/__test__/`
|
||||
server.middlewares.use((_req, res, next) => {
|
||||
const headers = server.config.server.headers
|
||||
if (headers) {
|
||||
for (const name in headers)
|
||||
for (const name in headers) {
|
||||
res.setHeader(name, headers[name]!)
|
||||
}
|
||||
}
|
||||
next()
|
||||
})
|
||||
let orchestratorScripts: string | undefined
|
||||
let testerScripts: string | undefined
|
||||
server.middlewares.use(async (req, res, next) => {
|
||||
if (!req.url)
|
||||
if (!req.url) {
|
||||
return next()
|
||||
}
|
||||
const url = new URL(req.url, 'http://localhost')
|
||||
if (!url.pathname.startsWith(testerPrefix) && url.pathname !== base)
|
||||
if (!url.pathname.startsWith(testerPrefix) && url.pathname !== base) {
|
||||
return next()
|
||||
}
|
||||
|
||||
res.setHeader('Cache-Control', 'no-cache, max-age=0, must-revalidate')
|
||||
res.setHeader(
|
||||
'Cache-Control',
|
||||
'no-cache, max-age=0, must-revalidate',
|
||||
)
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||
|
||||
const config = wrapConfig(project.getSerializableConfig())
|
||||
config.env ??= {}
|
||||
config.env.VITEST_BROWSER_DEBUG = process.env.VITEST_BROWSER_DEBUG || ''
|
||||
config.env.VITEST_BROWSER_DEBUG
|
||||
= process.env.VITEST_BROWSER_DEBUG || ''
|
||||
|
||||
// remove custom iframe related headers to allow the iframe to load
|
||||
res.removeHeader('X-Frame-Options')
|
||||
@ -72,8 +87,9 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
|
||||
let contextId = url.searchParams.get('contextId')
|
||||
// it's possible to open the page without a context,
|
||||
// for now, let's assume it should be the first one
|
||||
if (!contextId)
|
||||
if (!contextId) {
|
||||
contextId = project.browserState.keys().next().value ?? 'none'
|
||||
}
|
||||
|
||||
const files = project.browserState.get(contextId!)?.files ?? []
|
||||
|
||||
@ -83,15 +99,20 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
|
||||
root: project.browser!.config.root,
|
||||
}),
|
||||
__VITEST_FILES__: JSON.stringify(files),
|
||||
__VITEST_TYPE__: url.pathname === base ? '"orchestrator"' : '"tester"',
|
||||
__VITEST_TYPE__:
|
||||
url.pathname === base ? '"orchestrator"' : '"tester"',
|
||||
__VITEST_CONTEXT_ID__: JSON.stringify(contextId),
|
||||
})
|
||||
|
||||
// disable CSP for the orchestrator as we are the ones controlling it
|
||||
res.removeHeader('Content-Security-Policy')
|
||||
|
||||
if (!orchestratorScripts)
|
||||
orchestratorScripts = await formatScripts(project.config.browser.orchestratorScripts, server)
|
||||
if (!orchestratorScripts) {
|
||||
orchestratorScripts = await formatScripts(
|
||||
project.config.browser.orchestratorScripts,
|
||||
server,
|
||||
)
|
||||
}
|
||||
|
||||
let baseHtml = await orchestratorHtml
|
||||
|
||||
@ -99,14 +120,16 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
|
||||
if (project.config.browser.ui) {
|
||||
const manifestContent = await manifest
|
||||
const jsEntry = manifestContent['orchestrator.html'].file
|
||||
baseHtml = baseHtml.replaceAll('./assets/', `${base}__vitest__/assets/`).replace(
|
||||
'<!-- !LOAD_METADATA! -->',
|
||||
[
|
||||
'<script>{__VITEST_INJECTOR__}</script>',
|
||||
'{__VITEST_SCRIPTS__}',
|
||||
`<script type="module" crossorigin src="${jsEntry}"></script>`,
|
||||
].join('\n'),
|
||||
)
|
||||
baseHtml = baseHtml
|
||||
.replaceAll('./assets/', `${base}__vitest__/assets/`)
|
||||
.replace(
|
||||
'<!-- !LOAD_METADATA! -->',
|
||||
[
|
||||
'<script>{__VITEST_INJECTOR__}</script>',
|
||||
'{__VITEST_SCRIPTS__}',
|
||||
`<script type="module" crossorigin src="${jsEntry}"></script>`,
|
||||
].join('\n'),
|
||||
)
|
||||
}
|
||||
|
||||
const html = replacer(baseHtml, {
|
||||
@ -125,14 +148,23 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
|
||||
if (typeof csp === 'string') {
|
||||
// add frame-ancestors to allow the iframe to be loaded by Vitest,
|
||||
// but keep the rest of the CSP
|
||||
res.setHeader('Content-Security-Policy', csp.replace(/frame-ancestors [^;]+/, 'frame-ancestors *'))
|
||||
res.setHeader(
|
||||
'Content-Security-Policy',
|
||||
csp.replace(/frame-ancestors [^;]+/, 'frame-ancestors *'),
|
||||
)
|
||||
}
|
||||
|
||||
const [contextId, testFile] = url.pathname.slice(testerPrefix.length).split('/')
|
||||
const [contextId, testFile] = url.pathname
|
||||
.slice(testerPrefix.length)
|
||||
.split('/')
|
||||
const decodedTestFile = decodeURIComponent(testFile)
|
||||
const testFiles = await project.globTestFiles()
|
||||
// if decoded test file is "__vitest_all__" or not in the list of known files, run all tests
|
||||
const tests = decodedTestFile === '__vitest_all__' || !testFiles.includes(decodedTestFile) ? '__vitest_browser_runner__.files' : JSON.stringify([decodedTestFile])
|
||||
const tests
|
||||
= decodedTestFile === '__vitest_all__'
|
||||
|| !testFiles.includes(decodedTestFile)
|
||||
? '__vitest_browser_runner__.files'
|
||||
: JSON.stringify([decodedTestFile])
|
||||
const iframeId = JSON.stringify(decodedTestFile)
|
||||
const files = project.browserState.get(contextId)?.files ?? []
|
||||
|
||||
@ -142,12 +174,17 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
|
||||
__VITEST_VITE_CONFIG__: JSON.stringify({
|
||||
root: project.browser!.config.root,
|
||||
}),
|
||||
__VITEST_TYPE__: url.pathname === base ? '"orchestrator"' : '"tester"',
|
||||
__VITEST_TYPE__:
|
||||
url.pathname === base ? '"orchestrator"' : '"tester"',
|
||||
__VITEST_CONTEXT_ID__: JSON.stringify(contextId),
|
||||
})
|
||||
|
||||
if (!testerScripts)
|
||||
testerScripts = await formatScripts(project.config.browser.testerScripts, server)
|
||||
if (!testerScripts) {
|
||||
testerScripts = await formatScripts(
|
||||
project.config.browser.testerScripts,
|
||||
server,
|
||||
)
|
||||
}
|
||||
|
||||
const html = replacer(await testerHtml, {
|
||||
__VITEST_FAVICON__: favicon,
|
||||
@ -155,8 +192,8 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
|
||||
__VITEST_SCRIPTS__: testerScripts,
|
||||
__VITEST_INJECTOR__: injector,
|
||||
__VITEST_APPEND__:
|
||||
// TODO: have only a single global variable to not pollute the global scope
|
||||
`<script type="module">
|
||||
// TODO: have only a single global variable to not pollute the global scope
|
||||
`<script type="module">
|
||||
__vitest_browser_runner__.runningFiles = ${tests}
|
||||
__vitest_browser_runner__.iframeId = ${iframeId}
|
||||
__vitest_browser_runner__.runTests(__vitest_browser_runner__.runningFiles)
|
||||
@ -175,16 +212,26 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
|
||||
|
||||
const coverageFolder = resolveCoverageFolder(project)
|
||||
const coveragePath = coverageFolder ? coverageFolder[1] : undefined
|
||||
if (coveragePath && base === coveragePath)
|
||||
throw new Error(`The ui base path and the coverage path cannot be the same: ${base}, change coverage.reportsDirectory`)
|
||||
if (coveragePath && base === coveragePath) {
|
||||
throw new Error(
|
||||
`The ui base path and the coverage path cannot be the same: ${base}, change coverage.reportsDirectory`,
|
||||
)
|
||||
}
|
||||
|
||||
coverageFolder && server.middlewares.use(coveragePath!, sirv(coverageFolder[0], {
|
||||
single: true,
|
||||
dev: true,
|
||||
setHeaders: (res) => {
|
||||
res.setHeader('Cache-Control', 'public,max-age=0,must-revalidate')
|
||||
},
|
||||
}))
|
||||
coverageFolder
|
||||
&& server.middlewares.use(
|
||||
coveragePath!,
|
||||
sirv(coverageFolder[0], {
|
||||
single: true,
|
||||
dev: true,
|
||||
setHeaders: (res) => {
|
||||
res.setHeader(
|
||||
'Cache-Control',
|
||||
'public,max-age=0,must-revalidate',
|
||||
)
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -192,7 +239,9 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
|
||||
enforce: 'pre',
|
||||
async config() {
|
||||
const allTestFiles = await project.globTestFiles()
|
||||
const browserTestFiles = allTestFiles.filter(file => getFilePoolName(project, file) === 'browser')
|
||||
const browserTestFiles = allTestFiles.filter(
|
||||
file => getFilePoolName(project, file) === 'browser',
|
||||
)
|
||||
const setupFiles = toArray(project.config.setupFiles)
|
||||
const vitestPaths = [
|
||||
resolve(vitestDist, 'index.js'),
|
||||
@ -202,11 +251,7 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
|
||||
]
|
||||
return {
|
||||
optimizeDeps: {
|
||||
entries: [
|
||||
...browserTestFiles,
|
||||
...setupFiles,
|
||||
...vitestPaths,
|
||||
],
|
||||
entries: [...browserTestFiles, ...setupFiles, ...vitestPaths],
|
||||
exclude: [
|
||||
'vitest',
|
||||
'vitest/utils',
|
||||
@ -243,15 +288,18 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
|
||||
}
|
||||
},
|
||||
async resolveId(id) {
|
||||
if (!/\?browserv=\w+$/.test(id))
|
||||
if (!/\?browserv=\w+$/.test(id)) {
|
||||
return
|
||||
}
|
||||
|
||||
let useId = id.slice(0, id.lastIndexOf('?'))
|
||||
if (useId.startsWith('/@fs/'))
|
||||
if (useId.startsWith('/@fs/')) {
|
||||
useId = useId.slice(5)
|
||||
}
|
||||
|
||||
if (/^\w:/.test(useId))
|
||||
if (/^\w:/.test(useId)) {
|
||||
useId = useId.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
return useId
|
||||
},
|
||||
@ -262,16 +310,13 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
|
||||
if (rawId.startsWith('/__virtual_vitest__:')) {
|
||||
let id = rawId.slice('/__virtual_vitest__:'.length)
|
||||
// TODO: don't hardcode
|
||||
if (id === 'mocker-worker.js')
|
||||
if (id === 'mocker-worker.js') {
|
||||
id = 'msw/mockServiceWorker.js'
|
||||
}
|
||||
|
||||
const resolved = await this.resolve(
|
||||
id,
|
||||
distRoot,
|
||||
{
|
||||
skipSelf: true,
|
||||
},
|
||||
)
|
||||
const resolved = await this.resolve(id, distRoot, {
|
||||
skipSelf: true,
|
||||
})
|
||||
return resolved
|
||||
}
|
||||
},
|
||||
@ -292,7 +337,9 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
|
||||
const _require = createRequire(import.meta.url)
|
||||
build.onResolve({ filter: /@vue\/test-utils/ }, (args) => {
|
||||
// resolve to CJS instead of the browser because the browser version expects a global Vue object
|
||||
const resolved = _require.resolve(args.path, { paths: [args.importer] })
|
||||
const resolved = _require.resolve(args.path, {
|
||||
paths: [args.importer],
|
||||
})
|
||||
return { path: resolved }
|
||||
})
|
||||
},
|
||||
@ -310,15 +357,17 @@ function resolveCoverageFolder(project: WorkspaceProject) {
|
||||
const options = project.ctx.config
|
||||
const htmlReporter = options.coverage?.enabled
|
||||
? options.coverage.reporter.find((reporter) => {
|
||||
if (typeof reporter === 'string')
|
||||
if (typeof reporter === 'string') {
|
||||
return reporter === 'html'
|
||||
}
|
||||
|
||||
return reporter[0] === 'html'
|
||||
})
|
||||
: undefined
|
||||
|
||||
if (!htmlReporter)
|
||||
if (!htmlReporter) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// reportsDirectory not resolved yet
|
||||
const root = resolve(
|
||||
@ -326,12 +375,16 @@ function resolveCoverageFolder(project: WorkspaceProject) {
|
||||
options.coverage.reportsDirectory || coverageConfigDefaults.reportsDirectory,
|
||||
)
|
||||
|
||||
const subdir = (Array.isArray(htmlReporter) && htmlReporter.length > 1 && 'subdir' in htmlReporter[1])
|
||||
? htmlReporter[1].subdir
|
||||
: undefined
|
||||
const subdir
|
||||
= Array.isArray(htmlReporter)
|
||||
&& htmlReporter.length > 1
|
||||
&& 'subdir' in htmlReporter[1]
|
||||
? htmlReporter[1].subdir
|
||||
: undefined
|
||||
|
||||
if (!subdir || typeof subdir !== 'string')
|
||||
if (!subdir || typeof subdir !== 'string') {
|
||||
return [root, `/${basename(root)}/`]
|
||||
}
|
||||
|
||||
return [resolve(root, subdir), `/${basename(root)}/${subdir}/`]
|
||||
}
|
||||
@ -340,10 +393,9 @@ function wrapConfig(config: ResolvedConfig): ResolvedConfig {
|
||||
return {
|
||||
...config,
|
||||
// workaround RegExp serialization
|
||||
testNamePattern:
|
||||
config.testNamePattern
|
||||
? config.testNamePattern.toString() as any as RegExp
|
||||
: undefined,
|
||||
testNamePattern: config.testNamePattern
|
||||
? (config.testNamePattern.toString() as any as RegExp)
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@ -351,17 +403,30 @@ function replacer(code: string, values: Record<string, string>) {
|
||||
return code.replace(/\{\s*(\w+)\s*\}/g, (_, key) => values[key] ?? '')
|
||||
}
|
||||
|
||||
async function formatScripts(scripts: BrowserScript[] | undefined, server: ViteDevServer) {
|
||||
if (!scripts?.length)
|
||||
async function formatScripts(
|
||||
scripts: BrowserScript[] | undefined,
|
||||
server: ViteDevServer,
|
||||
) {
|
||||
if (!scripts?.length) {
|
||||
return ''
|
||||
const promises = scripts.map(async ({ content, src, async, id, type = 'module' }, index) => {
|
||||
const srcLink = (src ? (await server.pluginContainer.resolveId(src))?.id : undefined) || src
|
||||
const transformId = srcLink || join(server.config.root, `virtual__${id || `injected-${index}.js`}`)
|
||||
await server.moduleGraph.ensureEntryFromUrl(transformId)
|
||||
const contentProcessed = content && type === 'module'
|
||||
? (await server.pluginContainer.transform(content, transformId)).code
|
||||
: content
|
||||
return `<script type="${type}"${async ? ' async' : ''}${srcLink ? ` src="${slash(`/@fs/${srcLink}`)}"` : ''}>${contentProcessed || ''}</script>`
|
||||
})
|
||||
}
|
||||
const promises = scripts.map(
|
||||
async ({ content, src, async, id, type = 'module' }, index) => {
|
||||
const srcLink
|
||||
= (src ? (await server.pluginContainer.resolveId(src))?.id : undefined)
|
||||
|| src
|
||||
const transformId
|
||||
= srcLink
|
||||
|| join(server.config.root, `virtual__${id || `injected-${index}.js`}`)
|
||||
await server.moduleGraph.ensureEntryFromUrl(transformId)
|
||||
const contentProcessed
|
||||
= content && type === 'module'
|
||||
? (await server.pluginContainer.transform(content, transformId)).code
|
||||
: content
|
||||
return `<script type="${type}"${async ? ' async' : ''}${
|
||||
srcLink ? ` src="${slash(`/@fs/${srcLink}`)}"` : ''
|
||||
}>${contentProcessed || ''}</script>`
|
||||
},
|
||||
)
|
||||
return (await Promise.all(promises)).join('\n')
|
||||
}
|
||||
|
||||
@ -13,39 +13,55 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export default function BrowserContext(project: WorkspaceProject): Plugin {
|
||||
project.config.browser.commands ??= {}
|
||||
for (const [name, command] of Object.entries(builtinCommands))
|
||||
for (const [name, command] of Object.entries(builtinCommands)) {
|
||||
project.config.browser.commands[name] ??= command
|
||||
}
|
||||
|
||||
// validate names because they can't be used as identifiers
|
||||
for (const command in project.config.browser.commands) {
|
||||
if (!/^[a-z_$][\w$]*$/i.test(command))
|
||||
throw new Error(`Invalid command name "${command}". Only alphanumeric characters, $ and _ are allowed.`)
|
||||
if (!/^[a-z_$][\w$]*$/i.test(command)) {
|
||||
throw new Error(
|
||||
`Invalid command name "${command}". Only alphanumeric characters, $ and _ are allowed.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'vitest:browser:virtual-module:context',
|
||||
enforce: 'pre',
|
||||
resolveId(id) {
|
||||
if (id === ID_CONTEXT)
|
||||
if (id === ID_CONTEXT) {
|
||||
return VIRTUAL_ID_CONTEXT
|
||||
}
|
||||
},
|
||||
load(id) {
|
||||
if (id === VIRTUAL_ID_CONTEXT)
|
||||
if (id === VIRTUAL_ID_CONTEXT) {
|
||||
return generateContextFile.call(this, project)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function generateContextFile(this: PluginContext, project: WorkspaceProject) {
|
||||
async function generateContextFile(
|
||||
this: PluginContext,
|
||||
project: WorkspaceProject,
|
||||
) {
|
||||
const commands = Object.keys(project.config.browser.commands ?? {})
|
||||
const filepathCode = '__vitest_worker__.filepath || __vitest_worker__.current?.file?.filepath || undefined'
|
||||
const filepathCode
|
||||
= '__vitest_worker__.filepath || __vitest_worker__.current?.file?.filepath || undefined'
|
||||
const provider = project.browserProvider!
|
||||
|
||||
const commandsCode = commands.filter(command => !command.startsWith('__vitest')).map((command) => {
|
||||
return ` ["${command}"]: (...args) => rpc().triggerCommand(contextId, "${command}", filepath(), args),`
|
||||
}).join('\n')
|
||||
const commandsCode = commands
|
||||
.filter(command => !command.startsWith('__vitest'))
|
||||
.map((command) => {
|
||||
return ` ["${command}"]: (...args) => rpc().triggerCommand(contextId, "${command}", filepath(), args),`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
const userEventNonProviderImport = await getUserEventImport(provider, this.resolve.bind(this))
|
||||
const userEventNonProviderImport = await getUserEventImport(
|
||||
provider,
|
||||
this.resolve.bind(this),
|
||||
)
|
||||
const distContextPath = slash(`/@fs/${resolve(__dirname, 'context.js')}`)
|
||||
|
||||
return `
|
||||
@ -65,16 +81,25 @@ export const server = {
|
||||
}
|
||||
}
|
||||
export const commands = server.commands
|
||||
export const userEvent = ${provider.name === 'preview' ? '__vitest_user_event__' : '__userEvent_CDP__'}
|
||||
export const userEvent = ${
|
||||
provider.name === 'preview' ? '__vitest_user_event__' : '__userEvent_CDP__'
|
||||
}
|
||||
export { page }
|
||||
`
|
||||
}
|
||||
|
||||
async function getUserEventImport(provider: BrowserProvider, resolve: (id: string, importer: string) => Promise<null | { id: string }>) {
|
||||
if (provider.name !== 'preview')
|
||||
async function getUserEventImport(
|
||||
provider: BrowserProvider,
|
||||
resolve: (id: string, importer: string) => Promise<null | { id: string }>,
|
||||
) {
|
||||
if (provider.name !== 'preview') {
|
||||
return ''
|
||||
}
|
||||
const resolved = await resolve('@testing-library/user-event', __dirname)
|
||||
if (!resolved)
|
||||
if (!resolved) {
|
||||
throw new Error(`Failed to resolve user-event package from ${__dirname}`)
|
||||
return `import { userEvent as __vitest_user_event__ } from '${slash(`/@fs/${resolved.id}`)}'`
|
||||
}
|
||||
return `import { userEvent as __vitest_user_event__ } from '${slash(
|
||||
`/@fs/${resolved.id}`,
|
||||
)}'`
|
||||
}
|
||||
|
||||
@ -9,8 +9,9 @@ export default (): Plugin => {
|
||||
enforce: 'post',
|
||||
transform(source, id) {
|
||||
// TODO: test is not called for static imports
|
||||
if (!regexDynamicImport.test(source))
|
||||
if (!regexDynamicImport.test(source)) {
|
||||
return
|
||||
}
|
||||
return injectDynamicImport(source, id, this.parse)
|
||||
},
|
||||
}
|
||||
|
||||
@ -1,10 +1,21 @@
|
||||
import type { Browser, BrowserContext, BrowserContextOptions, LaunchOptions, Page } from 'playwright'
|
||||
import type { BrowserProvider, BrowserProviderInitializationOptions, WorkspaceProject } from 'vitest/node'
|
||||
import type {
|
||||
Browser,
|
||||
BrowserContext,
|
||||
BrowserContextOptions,
|
||||
LaunchOptions,
|
||||
Page,
|
||||
} from 'playwright'
|
||||
import type {
|
||||
BrowserProvider,
|
||||
BrowserProviderInitializationOptions,
|
||||
WorkspaceProject,
|
||||
} from 'vitest/node'
|
||||
|
||||
export const playwrightBrowsers = ['firefox', 'webkit', 'chromium'] as const
|
||||
export type PlaywrightBrowser = typeof playwrightBrowsers[number]
|
||||
export type PlaywrightBrowser = (typeof playwrightBrowsers)[number]
|
||||
|
||||
export interface PlaywrightProviderOptions extends BrowserProviderInitializationOptions {
|
||||
export interface PlaywrightProviderOptions
|
||||
extends BrowserProviderInitializationOptions {
|
||||
browser: PlaywrightBrowser
|
||||
}
|
||||
|
||||
@ -31,18 +42,23 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
|
||||
return playwrightBrowsers
|
||||
}
|
||||
|
||||
initialize(project: WorkspaceProject, { browser, options }: PlaywrightProviderOptions) {
|
||||
initialize(
|
||||
project: WorkspaceProject,
|
||||
{ browser, options }: PlaywrightProviderOptions,
|
||||
) {
|
||||
this.ctx = project
|
||||
this.browserName = browser
|
||||
this.options = options as any
|
||||
}
|
||||
|
||||
private async openBrowser() {
|
||||
if (this.browserPromise)
|
||||
if (this.browserPromise) {
|
||||
return this.browserPromise
|
||||
}
|
||||
|
||||
if (this.browser)
|
||||
if (this.browser) {
|
||||
return this.browser
|
||||
}
|
||||
|
||||
this.browserPromise = (async () => {
|
||||
const options = this.ctx.config.browser
|
||||
@ -62,8 +78,9 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
|
||||
}
|
||||
|
||||
private async createContext(contextId: string) {
|
||||
if (this.contexts.has(contextId))
|
||||
if (this.contexts.has(contextId)) {
|
||||
return this.contexts.get(contextId)!
|
||||
}
|
||||
|
||||
const browser = await this.openBrowser()
|
||||
const context = await browser.newContext({
|
||||
@ -77,8 +94,9 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
|
||||
|
||||
public getPage(contextId: string) {
|
||||
const page = this.pages.get(contextId)
|
||||
if (!page)
|
||||
if (!page) {
|
||||
throw new Error(`Page "${contextId}" not found`)
|
||||
}
|
||||
return page
|
||||
}
|
||||
|
||||
|
||||
@ -22,14 +22,18 @@ export class PreviewBrowserProvider implements BrowserProvider {
|
||||
async initialize(ctx: WorkspaceProject) {
|
||||
this.ctx = ctx
|
||||
this.open = false
|
||||
if (ctx.config.browser.headless)
|
||||
throw new Error('You\'ve enabled headless mode for "preview" provider but it doesn\'t support it. Use "playwright" or "webdriverio" instead: https://vitest.dev/guide/browser#configuration')
|
||||
if (ctx.config.browser.headless) {
|
||||
throw new Error(
|
||||
'You\'ve enabled headless mode for "preview" provider but it doesn\'t support it. Use "playwright" or "webdriverio" instead: https://vitest.dev/guide/browser#configuration',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async openPage(_contextId: string, url: string) {
|
||||
this.open = true
|
||||
if (!this.ctx.browser)
|
||||
if (!this.ctx.browser) {
|
||||
throw new Error('Browser is not initialized')
|
||||
}
|
||||
const options = this.ctx.browser.config.server
|
||||
const _open = options.open
|
||||
options.open = url
|
||||
@ -37,6 +41,5 @@ export class PreviewBrowserProvider implements BrowserProvider {
|
||||
options.open = _open
|
||||
}
|
||||
|
||||
async close() {
|
||||
}
|
||||
async close() {}
|
||||
}
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
import type { BrowserProvider, BrowserProviderInitializationOptions, WorkspaceProject } from 'vitest/node'
|
||||
import type {
|
||||
BrowserProvider,
|
||||
BrowserProviderInitializationOptions,
|
||||
WorkspaceProject,
|
||||
} from 'vitest/node'
|
||||
import type { RemoteOptions } from 'webdriverio'
|
||||
|
||||
const webdriverBrowsers = ['firefox', 'chrome', 'edge', 'safari'] as const
|
||||
type WebdriverBrowser = typeof webdriverBrowsers[number]
|
||||
type WebdriverBrowser = (typeof webdriverBrowsers)[number]
|
||||
|
||||
interface WebdriverProviderOptions extends BrowserProviderInitializationOptions {
|
||||
interface WebdriverProviderOptions
|
||||
extends BrowserProviderInitializationOptions {
|
||||
browser: WebdriverBrowser
|
||||
}
|
||||
|
||||
@ -23,7 +28,10 @@ export class WebdriverBrowserProvider implements BrowserProvider {
|
||||
return webdriverBrowsers
|
||||
}
|
||||
|
||||
async initialize(ctx: WorkspaceProject, { browser, options }: WebdriverProviderOptions) {
|
||||
async initialize(
|
||||
ctx: WorkspaceProject,
|
||||
{ browser, options }: WebdriverProviderOptions,
|
||||
) {
|
||||
this.ctx = ctx
|
||||
this.browserName = browser
|
||||
this.options = options as RemoteOptions
|
||||
@ -31,7 +39,10 @@ export class WebdriverBrowserProvider implements BrowserProvider {
|
||||
|
||||
async beforeCommand() {
|
||||
const page = this.browser!
|
||||
const iframe = await page.findElement('css selector', 'iframe[data-vitest]')
|
||||
const iframe = await page.findElement(
|
||||
'css selector',
|
||||
'iframe[data-vitest]',
|
||||
)
|
||||
await page.switchToFrame(iframe)
|
||||
}
|
||||
|
||||
@ -46,14 +57,18 @@ export class WebdriverBrowserProvider implements BrowserProvider {
|
||||
}
|
||||
|
||||
async openBrowser() {
|
||||
if (this.browser)
|
||||
if (this.browser) {
|
||||
return this.browser
|
||||
}
|
||||
|
||||
const options = this.ctx.config.browser
|
||||
|
||||
if (this.browserName === 'safari') {
|
||||
if (options.headless)
|
||||
throw new Error('You\'ve enabled headless mode for Safari but it doesn\'t currently support it.')
|
||||
if (options.headless) {
|
||||
throw new Error(
|
||||
'You\'ve enabled headless mode for Safari but it doesn\'t currently support it.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const { remote } = await import('webdriverio')
|
||||
@ -85,7 +100,7 @@ export class WebdriverBrowserProvider implements BrowserProvider {
|
||||
if (browser !== 'safari' && options.headless) {
|
||||
const [key, args] = headlessMap[browser]
|
||||
const currentValues = (this.options?.capabilities as any)?.[key] || {}
|
||||
const newArgs = [...currentValues.args || [], ...args]
|
||||
const newArgs = [...(currentValues.args || []), ...args]
|
||||
capabilities[key] = { ...currentValues, args: newArgs as any }
|
||||
}
|
||||
|
||||
|
||||
@ -1,10 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": [
|
||||
"node",
|
||||
"vite/client"
|
||||
]
|
||||
"types": ["node", "vite/client"]
|
||||
},
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
|
||||
@ -48,8 +48,6 @@ export default () => [
|
||||
format: 'esm',
|
||||
},
|
||||
external,
|
||||
plugins: [
|
||||
dts({ respectExternal: true }),
|
||||
],
|
||||
plugins: [dts({ respectExternal: true })],
|
||||
},
|
||||
]
|
||||
|
||||
@ -3,7 +3,9 @@ import { COVERAGE_STORE_KEY } from './constants'
|
||||
export async function getProvider() {
|
||||
// to not bundle the provider
|
||||
const providerPath = './provider.js'
|
||||
const { IstanbulCoverageProvider } = await import(providerPath) as typeof import('./provider')
|
||||
const { IstanbulCoverageProvider } = (await import(
|
||||
providerPath
|
||||
)) as typeof import('./provider')
|
||||
return new IstanbulCoverageProvider()
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,23 @@
|
||||
import { existsSync, promises as fs, readdirSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
existsSync,
|
||||
promises as fs,
|
||||
readdirSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { resolve } from 'pathe'
|
||||
import type { AfterSuiteRunMeta, CoverageIstanbulOptions, CoverageProvider, ReportContext, ResolvedCoverageOptions, Vitest } from 'vitest'
|
||||
import { coverageConfigDefaults, defaultExclude, defaultInclude } from 'vitest/config'
|
||||
import type {
|
||||
AfterSuiteRunMeta,
|
||||
CoverageIstanbulOptions,
|
||||
CoverageProvider,
|
||||
ReportContext,
|
||||
ResolvedCoverageOptions,
|
||||
Vitest,
|
||||
} from 'vitest'
|
||||
import {
|
||||
coverageConfigDefaults,
|
||||
defaultExclude,
|
||||
defaultInclude,
|
||||
} from 'vitest/config'
|
||||
import { BaseCoverageProvider } from 'vitest/coverage'
|
||||
import c from 'picocolors'
|
||||
import { parseModule } from 'magicast'
|
||||
@ -19,11 +35,16 @@ import { COVERAGE_STORE_KEY } from './constants'
|
||||
|
||||
type Options = ResolvedCoverageOptions<'istanbul'>
|
||||
type Filename = string
|
||||
type CoverageFilesByTransformMode = Record<AfterSuiteRunMeta['transformMode'], Filename[]>
|
||||
type ProjectName = NonNullable<AfterSuiteRunMeta['projectName']> | typeof DEFAULT_PROJECT
|
||||
type CoverageFilesByTransformMode = Record<
|
||||
AfterSuiteRunMeta['transformMode'],
|
||||
Filename[]
|
||||
>
|
||||
type ProjectName =
|
||||
| NonNullable<AfterSuiteRunMeta['projectName']>
|
||||
| typeof DEFAULT_PROJECT
|
||||
|
||||
interface TestExclude {
|
||||
new(opts: {
|
||||
new (opts: {
|
||||
cwd?: string | string[]
|
||||
include?: string | string[]
|
||||
exclude?: string | string[]
|
||||
@ -40,7 +61,9 @@ const DEFAULT_PROJECT = Symbol.for('default-project')
|
||||
const debug = createDebug('vitest:coverage')
|
||||
let uniqueId = 0
|
||||
|
||||
export class IstanbulCoverageProvider extends BaseCoverageProvider implements CoverageProvider {
|
||||
export class IstanbulCoverageProvider
|
||||
extends BaseCoverageProvider
|
||||
implements CoverageProvider {
|
||||
name = 'istanbul'
|
||||
|
||||
ctx!: Vitest
|
||||
@ -64,15 +87,22 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
|
||||
|
||||
// Resolved fields
|
||||
provider: 'istanbul',
|
||||
reportsDirectory: resolve(ctx.config.root, config.reportsDirectory || coverageConfigDefaults.reportsDirectory),
|
||||
reporter: this.resolveReporters(config.reporter || coverageConfigDefaults.reporter),
|
||||
reportsDirectory: resolve(
|
||||
ctx.config.root,
|
||||
config.reportsDirectory || coverageConfigDefaults.reportsDirectory,
|
||||
),
|
||||
reporter: this.resolveReporters(
|
||||
config.reporter || coverageConfigDefaults.reporter,
|
||||
),
|
||||
|
||||
thresholds: config.thresholds && {
|
||||
...config.thresholds,
|
||||
lines: config.thresholds['100'] ? 100 : config.thresholds.lines,
|
||||
branches: config.thresholds['100'] ? 100 : config.thresholds.branches,
|
||||
functions: config.thresholds['100'] ? 100 : config.thresholds.functions,
|
||||
statements: config.thresholds['100'] ? 100 : config.thresholds.statements,
|
||||
statements: config.thresholds['100']
|
||||
? 100
|
||||
: config.thresholds.statements,
|
||||
},
|
||||
}
|
||||
|
||||
@ -90,7 +120,10 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
|
||||
|
||||
this.testExclude = new _TestExclude({
|
||||
cwd: ctx.config.root,
|
||||
include: typeof this.options.include === 'undefined' ? undefined : [...this.options.include],
|
||||
include:
|
||||
typeof this.options.include === 'undefined'
|
||||
? undefined
|
||||
: [...this.options.include],
|
||||
exclude: [...defaultExclude, ...defaultInclude, ...this.options.exclude],
|
||||
excludeNodeModules: true,
|
||||
extension: this.options.extension,
|
||||
@ -98,9 +131,14 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
|
||||
})
|
||||
|
||||
const shard = this.ctx.config.shard
|
||||
const tempDirectory = `.tmp${shard ? `-${shard.index}-${shard.count}` : ''}`
|
||||
const tempDirectory = `.tmp${
|
||||
shard ? `-${shard.index}-${shard.count}` : ''
|
||||
}`
|
||||
|
||||
this.coverageFilesDirectory = resolve(this.options.reportsDirectory, tempDirectory)
|
||||
this.coverageFilesDirectory = resolve(
|
||||
this.options.reportsDirectory,
|
||||
tempDirectory,
|
||||
)
|
||||
}
|
||||
|
||||
resolveOptions() {
|
||||
@ -108,16 +146,24 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
|
||||
}
|
||||
|
||||
onFileTransform(sourceCode: string, id: string, pluginCtx: any) {
|
||||
if (!this.testExclude.shouldInstrument(id))
|
||||
if (!this.testExclude.shouldInstrument(id)) {
|
||||
return
|
||||
}
|
||||
|
||||
const sourceMap = pluginCtx.getCombinedSourcemap()
|
||||
sourceMap.sources = sourceMap.sources.map(removeQueryParameters)
|
||||
|
||||
// Exclude SWC's decorators that are left in source maps
|
||||
sourceCode = sourceCode.replaceAll('_ts_decorate', '/* istanbul ignore next */_ts_decorate')
|
||||
sourceCode = sourceCode.replaceAll(
|
||||
'_ts_decorate',
|
||||
'/* istanbul ignore next */_ts_decorate',
|
||||
)
|
||||
|
||||
const code = this.instrumenter.instrumentSync(sourceCode, id, sourceMap as any)
|
||||
const code = this.instrumenter.instrumentSync(
|
||||
sourceCode,
|
||||
id,
|
||||
sourceMap as any,
|
||||
)
|
||||
const map = this.instrumenter.lastSourceMap() as any
|
||||
|
||||
return { code, map }
|
||||
@ -129,11 +175,13 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
|
||||
* backwards compatibility is a breaking change.
|
||||
*/
|
||||
onAfterSuiteRun({ coverage, transformMode, projectName }: AfterSuiteRunMeta) {
|
||||
if (!coverage)
|
||||
if (!coverage) {
|
||||
return
|
||||
}
|
||||
|
||||
if (transformMode !== 'web' && transformMode !== 'ssr')
|
||||
if (transformMode !== 'web' && transformMode !== 'ssr') {
|
||||
throw new Error(`Invalid transform mode: ${transformMode}`)
|
||||
}
|
||||
|
||||
let entry = this.coverageFiles.get(projectName || DEFAULT_PROJECT)
|
||||
|
||||
@ -142,7 +190,10 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
|
||||
this.coverageFiles.set(projectName || DEFAULT_PROJECT, entry)
|
||||
}
|
||||
|
||||
const filename = resolve(this.coverageFilesDirectory, `coverage-${uniqueId++}.json`)
|
||||
const filename = resolve(
|
||||
this.coverageFilesDirectory,
|
||||
`coverage-${uniqueId++}.json`,
|
||||
)
|
||||
entry[transformMode].push(filename)
|
||||
|
||||
const promise = fs.writeFile(filename, JSON.stringify(coverage), 'utf-8')
|
||||
@ -150,11 +201,21 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
|
||||
}
|
||||
|
||||
async clean(clean = true) {
|
||||
if (clean && existsSync(this.options.reportsDirectory))
|
||||
await fs.rm(this.options.reportsDirectory, { recursive: true, force: true, maxRetries: 10 })
|
||||
if (clean && existsSync(this.options.reportsDirectory)) {
|
||||
await fs.rm(this.options.reportsDirectory, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 10,
|
||||
})
|
||||
}
|
||||
|
||||
if (existsSync(this.coverageFilesDirectory))
|
||||
await fs.rm(this.coverageFilesDirectory, { recursive: true, force: true, maxRetries: 10 })
|
||||
if (existsSync(this.coverageFilesDirectory)) {
|
||||
await fs.rm(this.coverageFilesDirectory, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 10,
|
||||
})
|
||||
}
|
||||
|
||||
await fs.mkdir(this.coverageFilesDirectory, { recursive: true })
|
||||
|
||||
@ -171,33 +232,45 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
|
||||
this.pendingPromises = []
|
||||
|
||||
for (const coveragePerProject of this.coverageFiles.values()) {
|
||||
for (const filenames of [coveragePerProject.ssr, coveragePerProject.web]) {
|
||||
for (const filenames of [
|
||||
coveragePerProject.ssr,
|
||||
coveragePerProject.web,
|
||||
]) {
|
||||
const coverageMapByTransformMode = libCoverage.createCoverageMap({})
|
||||
|
||||
for (const chunk of this.toSlices(filenames, this.options.processingConcurrency)) {
|
||||
for (const chunk of this.toSlices(
|
||||
filenames,
|
||||
this.options.processingConcurrency,
|
||||
)) {
|
||||
if (debug.enabled) {
|
||||
index += chunk.length
|
||||
debug('Covered files %d/%d', index, total)
|
||||
}
|
||||
|
||||
await Promise.all(chunk.map(async (filename) => {
|
||||
const contents = await fs.readFile(filename, 'utf-8')
|
||||
const coverage = JSON.parse(contents) as CoverageMap
|
||||
await Promise.all(
|
||||
chunk.map(async (filename) => {
|
||||
const contents = await fs.readFile(filename, 'utf-8')
|
||||
const coverage = JSON.parse(contents) as CoverageMap
|
||||
|
||||
coverageMapByTransformMode.merge(coverage)
|
||||
}))
|
||||
coverageMapByTransformMode.merge(coverage)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Source maps can change based on projectName and transform mode.
|
||||
// Coverage transform re-uses source maps so we need to separate transforms from each other.
|
||||
const transformedCoverage = await transformCoverage(coverageMapByTransformMode)
|
||||
const transformedCoverage = await transformCoverage(
|
||||
coverageMapByTransformMode,
|
||||
)
|
||||
coverageMap.merge(transformedCoverage)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.options.all && allTestsRun) {
|
||||
const coveredFiles = coverageMap.files()
|
||||
const uncoveredCoverage = await this.getCoverageMapForUncoveredFiles(coveredFiles)
|
||||
const uncoveredCoverage = await this.getCoverageMapForUncoveredFiles(
|
||||
coveredFiles,
|
||||
)
|
||||
|
||||
coverageMap.merge(await transformCoverage(uncoveredCoverage))
|
||||
}
|
||||
@ -207,7 +280,7 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
|
||||
|
||||
async reportCoverage(coverageMap: unknown, { allTestsRun }: ReportContext) {
|
||||
await this.generateReports(
|
||||
coverageMap as CoverageMap || libCoverage.createCoverageMap({}),
|
||||
(coverageMap as CoverageMap) || libCoverage.createCoverageMap({}),
|
||||
allTestsRun,
|
||||
)
|
||||
|
||||
@ -219,28 +292,37 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
|
||||
await fs.rm(this.coverageFilesDirectory, { recursive: true })
|
||||
|
||||
// Remove empty reports directory, e.g. when only text-reporter is used
|
||||
if (readdirSync(this.options.reportsDirectory).length === 0)
|
||||
if (readdirSync(this.options.reportsDirectory).length === 0) {
|
||||
await fs.rm(this.options.reportsDirectory, { recursive: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async generateReports(coverageMap: CoverageMap, allTestsRun: boolean | undefined) {
|
||||
async generateReports(
|
||||
coverageMap: CoverageMap,
|
||||
allTestsRun: boolean | undefined,
|
||||
) {
|
||||
const context = libReport.createContext({
|
||||
dir: this.options.reportsDirectory,
|
||||
coverageMap,
|
||||
watermarks: this.options.watermarks,
|
||||
})
|
||||
|
||||
if (this.hasTerminalReporter(this.options.reporter))
|
||||
this.ctx.logger.log(c.blue(' % ') + c.dim('Coverage report from ') + c.yellow(this.name))
|
||||
if (this.hasTerminalReporter(this.options.reporter)) {
|
||||
this.ctx.logger.log(
|
||||
c.blue(' % ') + c.dim('Coverage report from ') + c.yellow(this.name),
|
||||
)
|
||||
}
|
||||
|
||||
for (const reporter of this.options.reporter) {
|
||||
// Type assertion required for custom reporters
|
||||
reports.create(reporter[0] as Parameters<typeof reports.create>[0], {
|
||||
skipFull: this.options.skipFull,
|
||||
projectRoot: this.ctx.config.root,
|
||||
...reporter[1],
|
||||
}).execute(context)
|
||||
reports
|
||||
.create(reporter[0] as Parameters<typeof reports.create>[0], {
|
||||
skipFull: this.options.skipFull,
|
||||
projectRoot: this.ctx.config.root,
|
||||
...reporter[1],
|
||||
})
|
||||
.execute(context)
|
||||
}
|
||||
|
||||
if (this.options.thresholds) {
|
||||
@ -257,17 +339,27 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
|
||||
})
|
||||
|
||||
if (this.options.thresholds.autoUpdate && allTestsRun) {
|
||||
if (!this.ctx.server.config.configFile)
|
||||
throw new Error('Missing configurationFile. The "coverage.thresholds.autoUpdate" can only be enabled when configuration file is used.')
|
||||
if (!this.ctx.server.config.configFile) {
|
||||
throw new Error(
|
||||
'Missing configurationFile. The "coverage.thresholds.autoUpdate" can only be enabled when configuration file is used.',
|
||||
)
|
||||
}
|
||||
|
||||
const configFilePath = this.ctx.server.config.configFile
|
||||
const configModule = parseModule(await fs.readFile(configFilePath, 'utf8'))
|
||||
const configModule = parseModule(
|
||||
await fs.readFile(configFilePath, 'utf8'),
|
||||
)
|
||||
|
||||
this.updateThresholds({
|
||||
thresholds: resolvedThresholds,
|
||||
perFile: this.options.thresholds.perFile,
|
||||
configurationFile: configModule,
|
||||
onUpdate: () => writeFileSync(configFilePath, configModule.generate().code, 'utf-8'),
|
||||
onUpdate: () =>
|
||||
writeFileSync(
|
||||
configFilePath,
|
||||
configModule.generate().code,
|
||||
'utf-8',
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -276,18 +368,24 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
|
||||
async mergeReports(coverageMaps: unknown[]) {
|
||||
const coverageMap = libCoverage.createCoverageMap({})
|
||||
|
||||
for (const coverage of coverageMaps)
|
||||
for (const coverage of coverageMaps) {
|
||||
coverageMap.merge(coverage as CoverageMap)
|
||||
}
|
||||
|
||||
await this.generateReports(coverageMap, true)
|
||||
}
|
||||
|
||||
private async getCoverageMapForUncoveredFiles(coveredFiles: string[]) {
|
||||
const allFiles = await this.testExclude.glob(this.ctx.config.root)
|
||||
let includedFiles = allFiles.map(file => resolve(this.ctx.config.root, file))
|
||||
let includedFiles = allFiles.map(file =>
|
||||
resolve(this.ctx.config.root, file),
|
||||
)
|
||||
|
||||
if (this.ctx.config.changed)
|
||||
includedFiles = (this.ctx.config.related || []).filter(file => includedFiles.includes(file))
|
||||
if (this.ctx.config.changed) {
|
||||
includedFiles = (this.ctx.config.related || []).filter(file =>
|
||||
includedFiles.includes(file),
|
||||
)
|
||||
}
|
||||
|
||||
const uncoveredFiles = includedFiles
|
||||
.filter(file => !coveredFiles.includes(file))
|
||||
|
||||
@ -49,8 +49,6 @@ export default () => [
|
||||
format: 'esm',
|
||||
},
|
||||
external,
|
||||
plugins: [
|
||||
dts({ respectExternal: true }),
|
||||
],
|
||||
plugins: [dts({ respectExternal: true })],
|
||||
},
|
||||
]
|
||||
|
||||
@ -5,7 +5,9 @@ export default {
|
||||
async getProvider() {
|
||||
// to not bundle the provider
|
||||
const name = './provider.js'
|
||||
const { V8CoverageProvider } = await import(name) as typeof import('./provider')
|
||||
const { V8CoverageProvider } = (await import(
|
||||
name
|
||||
)) as typeof import('./provider')
|
||||
return new V8CoverageProvider()
|
||||
},
|
||||
}
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import { existsSync, promises as fs, readdirSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
existsSync,
|
||||
promises as fs,
|
||||
readdirSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import type { Profiler } from 'node:inspector'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import v8ToIstanbul from 'v8-to-istanbul'
|
||||
@ -18,16 +23,26 @@ import { stripLiteral } from 'strip-literal'
|
||||
import createDebug from 'debug'
|
||||
import { cleanUrl } from 'vite-node/utils'
|
||||
import type { EncodedSourceMap, FetchResult } from 'vite-node'
|
||||
import { coverageConfigDefaults, defaultExclude, defaultInclude } from 'vitest/config'
|
||||
import {
|
||||
coverageConfigDefaults,
|
||||
defaultExclude,
|
||||
defaultInclude,
|
||||
} from 'vitest/config'
|
||||
import { BaseCoverageProvider } from 'vitest/coverage'
|
||||
import type { AfterSuiteRunMeta, CoverageProvider, CoverageV8Options, ReportContext, ResolvedCoverageOptions } from 'vitest'
|
||||
import type {
|
||||
AfterSuiteRunMeta,
|
||||
CoverageProvider,
|
||||
CoverageV8Options,
|
||||
ReportContext,
|
||||
ResolvedCoverageOptions,
|
||||
} from 'vitest'
|
||||
import type { Vitest } from 'vitest/node'
|
||||
|
||||
// @ts-expect-error missing types
|
||||
import _TestExclude from 'test-exclude'
|
||||
|
||||
interface TestExclude {
|
||||
new(opts: {
|
||||
new (opts: {
|
||||
cwd?: string | string[]
|
||||
include?: string | string[]
|
||||
exclude?: string | string[]
|
||||
@ -44,21 +59,30 @@ type Options = ResolvedCoverageOptions<'v8'>
|
||||
type TransformResults = Map<string, FetchResult>
|
||||
type Filename = string
|
||||
type RawCoverage = Profiler.TakePreciseCoverageReturnType
|
||||
type CoverageFilesByTransformMode = Record<AfterSuiteRunMeta['transformMode'], Filename[]>
|
||||
type ProjectName = NonNullable<AfterSuiteRunMeta['projectName']> | typeof DEFAULT_PROJECT
|
||||
type CoverageFilesByTransformMode = Record<
|
||||
AfterSuiteRunMeta['transformMode'],
|
||||
Filename[]
|
||||
>
|
||||
type ProjectName =
|
||||
| NonNullable<AfterSuiteRunMeta['projectName']>
|
||||
| typeof DEFAULT_PROJECT
|
||||
|
||||
// TODO: vite-node should export this
|
||||
const WRAPPER_LENGTH = 185
|
||||
|
||||
// Note that this needs to match the line ending as well
|
||||
const VITE_EXPORTS_LINE_PATTERN = /Object\.defineProperty\(__vite_ssr_exports__.*\n/g
|
||||
const DECORATOR_METADATA_PATTERN = /_ts_metadata\("design:paramtypes", \[[^\]]*\]\),*/g
|
||||
const VITE_EXPORTS_LINE_PATTERN
|
||||
= /Object\.defineProperty\(__vite_ssr_exports__.*\n/g
|
||||
const DECORATOR_METADATA_PATTERN
|
||||
= /_ts_metadata\("design:paramtypes", \[[^\]]*\]\),*/g
|
||||
const DEFAULT_PROJECT = Symbol.for('default-project')
|
||||
|
||||
const debug = createDebug('vitest:coverage')
|
||||
let uniqueId = 0
|
||||
|
||||
export class V8CoverageProvider extends BaseCoverageProvider implements CoverageProvider {
|
||||
export class V8CoverageProvider
|
||||
extends BaseCoverageProvider
|
||||
implements CoverageProvider {
|
||||
name = 'v8'
|
||||
|
||||
ctx!: Vitest
|
||||
@ -81,21 +105,31 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
|
||||
|
||||
// Resolved fields
|
||||
provider: 'v8',
|
||||
reporter: this.resolveReporters(config.reporter || coverageConfigDefaults.reporter),
|
||||
reportsDirectory: resolve(ctx.config.root, config.reportsDirectory || coverageConfigDefaults.reportsDirectory),
|
||||
reporter: this.resolveReporters(
|
||||
config.reporter || coverageConfigDefaults.reporter,
|
||||
),
|
||||
reportsDirectory: resolve(
|
||||
ctx.config.root,
|
||||
config.reportsDirectory || coverageConfigDefaults.reportsDirectory,
|
||||
),
|
||||
|
||||
thresholds: config.thresholds && {
|
||||
...config.thresholds,
|
||||
lines: config.thresholds['100'] ? 100 : config.thresholds.lines,
|
||||
branches: config.thresholds['100'] ? 100 : config.thresholds.branches,
|
||||
functions: config.thresholds['100'] ? 100 : config.thresholds.functions,
|
||||
statements: config.thresholds['100'] ? 100 : config.thresholds.statements,
|
||||
statements: config.thresholds['100']
|
||||
? 100
|
||||
: config.thresholds.statements,
|
||||
},
|
||||
}
|
||||
|
||||
this.testExclude = new _TestExclude({
|
||||
cwd: ctx.config.root,
|
||||
include: typeof this.options.include === 'undefined' ? undefined : [...this.options.include],
|
||||
include:
|
||||
typeof this.options.include === 'undefined'
|
||||
? undefined
|
||||
: [...this.options.include],
|
||||
exclude: [...defaultExclude, ...defaultInclude, ...this.options.exclude],
|
||||
excludeNodeModules: true,
|
||||
extension: this.options.extension,
|
||||
@ -103,9 +137,14 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
|
||||
})
|
||||
|
||||
const shard = this.ctx.config.shard
|
||||
const tempDirectory = `.tmp${shard ? `-${shard.index}-${shard.count}` : ''}`
|
||||
const tempDirectory = `.tmp${
|
||||
shard ? `-${shard.index}-${shard.count}` : ''
|
||||
}`
|
||||
|
||||
this.coverageFilesDirectory = resolve(this.options.reportsDirectory, tempDirectory)
|
||||
this.coverageFilesDirectory = resolve(
|
||||
this.options.reportsDirectory,
|
||||
tempDirectory,
|
||||
)
|
||||
}
|
||||
|
||||
resolveOptions() {
|
||||
@ -113,11 +152,21 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
|
||||
}
|
||||
|
||||
async clean(clean = true) {
|
||||
if (clean && existsSync(this.options.reportsDirectory))
|
||||
await fs.rm(this.options.reportsDirectory, { recursive: true, force: true, maxRetries: 10 })
|
||||
if (clean && existsSync(this.options.reportsDirectory)) {
|
||||
await fs.rm(this.options.reportsDirectory, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 10,
|
||||
})
|
||||
}
|
||||
|
||||
if (existsSync(this.coverageFilesDirectory))
|
||||
await fs.rm(this.coverageFilesDirectory, { recursive: true, force: true, maxRetries: 10 })
|
||||
if (existsSync(this.coverageFilesDirectory)) {
|
||||
await fs.rm(this.coverageFilesDirectory, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 10,
|
||||
})
|
||||
}
|
||||
|
||||
await fs.mkdir(this.coverageFilesDirectory, { recursive: true })
|
||||
|
||||
@ -131,8 +180,9 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
|
||||
* backwards compatibility is a breaking change.
|
||||
*/
|
||||
onAfterSuiteRun({ coverage, transformMode, projectName }: AfterSuiteRunMeta) {
|
||||
if (transformMode !== 'web' && transformMode !== 'ssr')
|
||||
if (transformMode !== 'web' && transformMode !== 'ssr') {
|
||||
throw new Error(`Invalid transform mode: ${transformMode}`)
|
||||
}
|
||||
|
||||
let entry = this.coverageFiles.get(projectName || DEFAULT_PROJECT)
|
||||
|
||||
@ -141,7 +191,10 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
|
||||
this.coverageFiles.set(projectName || DEFAULT_PROJECT, entry)
|
||||
}
|
||||
|
||||
const filename = resolve(this.coverageFilesDirectory, `coverage-${uniqueId++}.json`)
|
||||
const filename = resolve(
|
||||
this.coverageFilesDirectory,
|
||||
`coverage-${uniqueId++}.json`,
|
||||
)
|
||||
entry[transformMode].push(filename)
|
||||
|
||||
const promise = fs.writeFile(filename, JSON.stringify(coverage), 'utf-8')
|
||||
@ -156,24 +209,38 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
|
||||
await Promise.all(this.pendingPromises)
|
||||
this.pendingPromises = []
|
||||
|
||||
for (const [projectName, coveragePerProject] of this.coverageFiles.entries()) {
|
||||
for (const [transformMode, filenames] of Object.entries(coveragePerProject) as [AfterSuiteRunMeta['transformMode'], Filename[]][]) {
|
||||
for (const [
|
||||
projectName,
|
||||
coveragePerProject,
|
||||
] of this.coverageFiles.entries()) {
|
||||
for (const [transformMode, filenames] of Object.entries(
|
||||
coveragePerProject,
|
||||
) as [AfterSuiteRunMeta['transformMode'], Filename[]][]) {
|
||||
let merged: RawCoverage = { result: [] }
|
||||
|
||||
for (const chunk of this.toSlices(filenames, this.options.processingConcurrency)) {
|
||||
for (const chunk of this.toSlices(
|
||||
filenames,
|
||||
this.options.processingConcurrency,
|
||||
)) {
|
||||
if (debug.enabled) {
|
||||
index += chunk.length
|
||||
debug('Covered files %d/%d', index, total)
|
||||
}
|
||||
|
||||
await Promise.all(chunk.map(async (filename) => {
|
||||
const contents = await fs.readFile(filename, 'utf-8')
|
||||
const coverage = JSON.parse(contents) as RawCoverage
|
||||
merged = mergeProcessCovs([merged, coverage])
|
||||
}))
|
||||
await Promise.all(
|
||||
chunk.map(async (filename) => {
|
||||
const contents = await fs.readFile(filename, 'utf-8')
|
||||
const coverage = JSON.parse(contents) as RawCoverage
|
||||
merged = mergeProcessCovs([merged, coverage])
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const converted = await this.convertCoverage(merged, projectName, transformMode)
|
||||
const converted = await this.convertCoverage(
|
||||
merged,
|
||||
projectName,
|
||||
transformMode,
|
||||
)
|
||||
|
||||
// Source maps can change based on projectName and transform mode.
|
||||
// Coverage transform re-uses source maps so we need to separate transforms from each other.
|
||||
@ -194,11 +261,17 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
|
||||
}
|
||||
|
||||
async reportCoverage(coverageMap: unknown, { allTestsRun }: ReportContext) {
|
||||
if (provider === 'stackblitz')
|
||||
this.ctx.logger.log(c.blue(' % ') + c.yellow('@vitest/coverage-v8 does not work on Stackblitz. Report will be empty.'))
|
||||
if (provider === 'stackblitz') {
|
||||
this.ctx.logger.log(
|
||||
c.blue(' % ')
|
||||
+ c.yellow(
|
||||
'@vitest/coverage-v8 does not work on Stackblitz. Report will be empty.',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
await this.generateReports(
|
||||
coverageMap as CoverageMap || libCoverage.createCoverageMap({}),
|
||||
(coverageMap as CoverageMap) || libCoverage.createCoverageMap({}),
|
||||
allTestsRun,
|
||||
)
|
||||
|
||||
@ -210,8 +283,9 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
|
||||
await fs.rm(this.coverageFilesDirectory, { recursive: true })
|
||||
|
||||
// Remove empty reports directory, e.g. when only text-reporter is used
|
||||
if (readdirSync(this.options.reportsDirectory).length === 0)
|
||||
if (readdirSync(this.options.reportsDirectory).length === 0) {
|
||||
await fs.rm(this.options.reportsDirectory, { recursive: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -222,16 +296,21 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
|
||||
watermarks: this.options.watermarks,
|
||||
})
|
||||
|
||||
if (this.hasTerminalReporter(this.options.reporter))
|
||||
this.ctx.logger.log(c.blue(' % ') + c.dim('Coverage report from ') + c.yellow(this.name))
|
||||
if (this.hasTerminalReporter(this.options.reporter)) {
|
||||
this.ctx.logger.log(
|
||||
c.blue(' % ') + c.dim('Coverage report from ') + c.yellow(this.name),
|
||||
)
|
||||
}
|
||||
|
||||
for (const reporter of this.options.reporter) {
|
||||
// Type assertion required for custom reporters
|
||||
reports.create(reporter[0] as Parameters<typeof reports.create>[0], {
|
||||
skipFull: this.options.skipFull,
|
||||
projectRoot: this.ctx.config.root,
|
||||
...reporter[1],
|
||||
}).execute(context)
|
||||
reports
|
||||
.create(reporter[0] as Parameters<typeof reports.create>[0], {
|
||||
skipFull: this.options.skipFull,
|
||||
projectRoot: this.ctx.config.root,
|
||||
...reporter[1],
|
||||
})
|
||||
.execute(context)
|
||||
}
|
||||
|
||||
if (this.options.thresholds) {
|
||||
@ -248,17 +327,27 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
|
||||
})
|
||||
|
||||
if (this.options.thresholds.autoUpdate && allTestsRun) {
|
||||
if (!this.ctx.server.config.configFile)
|
||||
throw new Error('Missing configurationFile. The "coverage.thresholds.autoUpdate" can only be enabled when configuration file is used.')
|
||||
if (!this.ctx.server.config.configFile) {
|
||||
throw new Error(
|
||||
'Missing configurationFile. The "coverage.thresholds.autoUpdate" can only be enabled when configuration file is used.',
|
||||
)
|
||||
}
|
||||
|
||||
const configFilePath = this.ctx.server.config.configFile
|
||||
const configModule = parseModule(await fs.readFile(configFilePath, 'utf8'))
|
||||
const configModule = parseModule(
|
||||
await fs.readFile(configFilePath, 'utf8'),
|
||||
)
|
||||
|
||||
this.updateThresholds({
|
||||
thresholds: resolvedThresholds,
|
||||
perFile: this.options.thresholds.perFile,
|
||||
configurationFile: configModule,
|
||||
onUpdate: () => writeFileSync(configFilePath, configModule.generate().code, 'utf-8'),
|
||||
onUpdate: () =>
|
||||
writeFileSync(
|
||||
configFilePath,
|
||||
configModule.generate().code,
|
||||
'utf-8',
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -267,20 +356,28 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
|
||||
async mergeReports(coverageMaps: unknown[]) {
|
||||
const coverageMap = libCoverage.createCoverageMap({})
|
||||
|
||||
for (const coverage of coverageMaps)
|
||||
for (const coverage of coverageMaps) {
|
||||
coverageMap.merge(coverage as CoverageMap)
|
||||
}
|
||||
|
||||
await this.generateReports(coverageMap, true)
|
||||
}
|
||||
|
||||
private async getUntestedFiles(testedFiles: string[]): Promise<RawCoverage> {
|
||||
const transformResults = normalizeTransformResults(this.ctx.vitenode.fetchCache)
|
||||
const transformResults = normalizeTransformResults(
|
||||
this.ctx.vitenode.fetchCache,
|
||||
)
|
||||
|
||||
const allFiles = await this.testExclude.glob(this.ctx.config.root)
|
||||
let includedFiles = allFiles.map(file => resolve(this.ctx.config.root, file))
|
||||
let includedFiles = allFiles.map(file =>
|
||||
resolve(this.ctx.config.root, file),
|
||||
)
|
||||
|
||||
if (this.ctx.config.changed)
|
||||
includedFiles = (this.ctx.config.related || []).filter(file => includedFiles.includes(file))
|
||||
if (this.ctx.config.changed) {
|
||||
includedFiles = (this.ctx.config.related || []).filter(file =>
|
||||
includedFiles.includes(file),
|
||||
)
|
||||
}
|
||||
|
||||
const uncoveredFiles = includedFiles
|
||||
.map(file => pathToFileURL(file))
|
||||
@ -289,48 +386,67 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
|
||||
let merged: RawCoverage = { result: [] }
|
||||
let index = 0
|
||||
|
||||
for (const chunk of this.toSlices(uncoveredFiles, this.options.processingConcurrency)) {
|
||||
for (const chunk of this.toSlices(
|
||||
uncoveredFiles,
|
||||
this.options.processingConcurrency,
|
||||
)) {
|
||||
if (debug.enabled) {
|
||||
index += chunk.length
|
||||
debug('Uncovered files %d/%d', index, uncoveredFiles.length)
|
||||
}
|
||||
|
||||
const coverages = await Promise.all(chunk.map(async (filename) => {
|
||||
const { originalSource, source } = await this.getSources(filename.href, transformResults)
|
||||
const coverages = await Promise.all(
|
||||
chunk.map(async (filename) => {
|
||||
const { originalSource, source } = await this.getSources(
|
||||
filename.href,
|
||||
transformResults,
|
||||
)
|
||||
|
||||
// Ignore empty files, e.g. files that contain only typescript types and no runtime code
|
||||
if (source && stripLiteral(source).trim() === '')
|
||||
return null
|
||||
// Ignore empty files, e.g. files that contain only typescript types and no runtime code
|
||||
if (source && stripLiteral(source).trim() === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
const coverage = {
|
||||
url: filename.href,
|
||||
scriptId: '0',
|
||||
// Create a made up function to mark whole file as uncovered. Note that this does not exist in source maps.
|
||||
functions: [{
|
||||
ranges: [{
|
||||
startOffset: 0,
|
||||
endOffset: originalSource.length,
|
||||
count: 0,
|
||||
}],
|
||||
isBlockCoverage: true,
|
||||
// This is magical value that indicates an empty report: https://github.com/istanbuljs/v8-to-istanbul/blob/fca5e6a9e6ef38a9cdc3a178d5a6cf9ef82e6cab/lib/v8-to-istanbul.js#LL131C40-L131C40
|
||||
functionName: '(empty-report)',
|
||||
}],
|
||||
}
|
||||
const coverage = {
|
||||
url: filename.href,
|
||||
scriptId: '0',
|
||||
// Create a made up function to mark whole file as uncovered. Note that this does not exist in source maps.
|
||||
functions: [
|
||||
{
|
||||
ranges: [
|
||||
{
|
||||
startOffset: 0,
|
||||
endOffset: originalSource.length,
|
||||
count: 0,
|
||||
},
|
||||
],
|
||||
isBlockCoverage: true,
|
||||
// This is magical value that indicates an empty report: https://github.com/istanbuljs/v8-to-istanbul/blob/fca5e6a9e6ef38a9cdc3a178d5a6cf9ef82e6cab/lib/v8-to-istanbul.js#LL131C40-L131C40
|
||||
functionName: '(empty-report)',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
return { result: [coverage] }
|
||||
}))
|
||||
return { result: [coverage] }
|
||||
}),
|
||||
)
|
||||
|
||||
merged = mergeProcessCovs([
|
||||
merged,
|
||||
...coverages.filter((cov): cov is NonNullable<typeof cov> => cov != null),
|
||||
...coverages.filter(
|
||||
(cov): cov is NonNullable<typeof cov> => cov != null,
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
private async getSources(url: string, transformResults: TransformResults, functions: Profiler.FunctionCoverage[] = []): Promise<{
|
||||
private async getSources(
|
||||
url: string,
|
||||
transformResults: TransformResults,
|
||||
functions: Profiler.FunctionCoverage[] = [],
|
||||
): Promise<{
|
||||
source: string
|
||||
originalSource: string
|
||||
sourceMap?: { sourcemap: EncodedSourceMap }
|
||||
@ -339,21 +455,28 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
|
||||
const filePath = normalize(fileURLToPath(url))
|
||||
|
||||
let isExecuted = true
|
||||
let transformResult: FetchResult | Awaited<ReturnType<typeof this.ctx.vitenode.transformRequest>> = transformResults.get(filePath)
|
||||
let transformResult:
|
||||
| FetchResult
|
||||
| Awaited<ReturnType<typeof this.ctx.vitenode.transformRequest>>
|
||||
= transformResults.get(filePath)
|
||||
|
||||
if (!transformResult) {
|
||||
isExecuted = false
|
||||
transformResult = await this.ctx.vitenode.transformRequest(filePath).catch(() => null)
|
||||
transformResult = await this.ctx.vitenode
|
||||
.transformRequest(filePath)
|
||||
.catch(() => null)
|
||||
}
|
||||
|
||||
const map = transformResult?.map as (EncodedSourceMap | undefined)
|
||||
const map = transformResult?.map as EncodedSourceMap | undefined
|
||||
const code = transformResult?.code
|
||||
const sourcesContent = map?.sourcesContent?.[0] || await fs.readFile(filePath, 'utf-8').catch(() => {
|
||||
// If file does not exist construct a dummy source for it.
|
||||
// These can be files that were generated dynamically during the test run and were removed after it.
|
||||
const length = findLongestFunctionLength(functions)
|
||||
return '.'.repeat(length)
|
||||
})
|
||||
const sourcesContent
|
||||
= map?.sourcesContent?.[0]
|
||||
|| (await fs.readFile(filePath, 'utf-8').catch(() => {
|
||||
// If file does not exist construct a dummy source for it.
|
||||
// These can be files that were generated dynamically during the test run and were removed after it.
|
||||
const length = findLongestFunctionLength(functions)
|
||||
return '.'.repeat(length)
|
||||
}))
|
||||
|
||||
// These can be uncovered files included by "all: true" or files that are loaded outside vite-node
|
||||
if (!map) {
|
||||
@ -365,8 +488,9 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
|
||||
}
|
||||
|
||||
const sources = [url]
|
||||
if (map.sources && map.sources[0] && !url.endsWith(map.sources[0]))
|
||||
if (map.sources && map.sources[0] && !url.endsWith(map.sources[0])) {
|
||||
sources[0] = new URL(map.sources[0], url).href
|
||||
}
|
||||
|
||||
return {
|
||||
isExecuted,
|
||||
@ -383,33 +507,58 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
|
||||
}
|
||||
}
|
||||
|
||||
private async convertCoverage(coverage: RawCoverage, projectName?: ProjectName, transformMode?: 'web' | 'ssr'): Promise<CoverageMap> {
|
||||
const viteNode = this.ctx.projects.find(project => project.getName() === projectName)?.vitenode || this.ctx.vitenode
|
||||
const fetchCache = transformMode ? viteNode.fetchCaches[transformMode] : viteNode.fetchCache
|
||||
private async convertCoverage(
|
||||
coverage: RawCoverage,
|
||||
projectName?: ProjectName,
|
||||
transformMode?: 'web' | 'ssr',
|
||||
): Promise<CoverageMap> {
|
||||
const viteNode
|
||||
= this.ctx.projects.find(project => project.getName() === projectName)
|
||||
?.vitenode || this.ctx.vitenode
|
||||
const fetchCache = transformMode
|
||||
? viteNode.fetchCaches[transformMode]
|
||||
: viteNode.fetchCache
|
||||
const transformResults = normalizeTransformResults(fetchCache)
|
||||
|
||||
const scriptCoverages = coverage.result.filter(result => this.testExclude.shouldInstrument(fileURLToPath(result.url)))
|
||||
const scriptCoverages = coverage.result.filter(result =>
|
||||
this.testExclude.shouldInstrument(fileURLToPath(result.url)),
|
||||
)
|
||||
const coverageMap = libCoverage.createCoverageMap({})
|
||||
let index = 0
|
||||
|
||||
for (const chunk of this.toSlices(scriptCoverages, this.options.processingConcurrency)) {
|
||||
for (const chunk of this.toSlices(
|
||||
scriptCoverages,
|
||||
this.options.processingConcurrency,
|
||||
)) {
|
||||
if (debug.enabled) {
|
||||
index += chunk.length
|
||||
debug('Converting %d/%d', index, scriptCoverages.length)
|
||||
}
|
||||
|
||||
await Promise.all(chunk.map(async ({ url, functions }) => {
|
||||
const sources = await this.getSources(url, transformResults, functions)
|
||||
await Promise.all(
|
||||
chunk.map(async ({ url, functions }) => {
|
||||
const sources = await this.getSources(
|
||||
url,
|
||||
transformResults,
|
||||
functions,
|
||||
)
|
||||
|
||||
// If file was executed by vite-node we'll need to add its wrapper
|
||||
const wrapperLength = sources.isExecuted ? WRAPPER_LENGTH : 0
|
||||
// If file was executed by vite-node we'll need to add its wrapper
|
||||
const wrapperLength = sources.isExecuted ? WRAPPER_LENGTH : 0
|
||||
|
||||
const converter = v8ToIstanbul(url, wrapperLength, sources, undefined, this.options.ignoreEmptyLines)
|
||||
await converter.load()
|
||||
const converter = v8ToIstanbul(
|
||||
url,
|
||||
wrapperLength,
|
||||
sources,
|
||||
undefined,
|
||||
this.options.ignoreEmptyLines,
|
||||
)
|
||||
await converter.load()
|
||||
|
||||
converter.applyCoverage(functions)
|
||||
coverageMap.merge(converter.toIstanbul())
|
||||
}))
|
||||
converter.applyCoverage(functions)
|
||||
coverageMap.merge(converter.toIstanbul())
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return coverageMap
|
||||
@ -426,16 +575,25 @@ async function transformCoverage(coverageMap: CoverageMap) {
|
||||
* - Vite's export helpers: e.g. `Object.defineProperty(__vite_ssr_exports__, "sum", { enumerable: true, configurable: true, get(){ return sum }});`
|
||||
* - SWC's decorator metadata: e.g. `_ts_metadata("design:paramtypes", [\ntypeof Request === "undefined" ? Object : Request\n]),`
|
||||
*/
|
||||
function excludeGeneratedCode(source: string | undefined, map: EncodedSourceMap) {
|
||||
if (!source)
|
||||
function excludeGeneratedCode(
|
||||
source: string | undefined,
|
||||
map: EncodedSourceMap,
|
||||
) {
|
||||
if (!source) {
|
||||
return map
|
||||
}
|
||||
|
||||
if (!source.match(VITE_EXPORTS_LINE_PATTERN) && !source.match(DECORATOR_METADATA_PATTERN))
|
||||
if (
|
||||
!source.match(VITE_EXPORTS_LINE_PATTERN)
|
||||
&& !source.match(DECORATOR_METADATA_PATTERN)
|
||||
) {
|
||||
return map
|
||||
}
|
||||
|
||||
const trimmed = new MagicString(source)
|
||||
trimmed.replaceAll(VITE_EXPORTS_LINE_PATTERN, '\n')
|
||||
trimmed.replaceAll(DECORATOR_METADATA_PATTERN, match => '\n'.repeat(match.split('\n').length - 1))
|
||||
trimmed.replaceAll(DECORATOR_METADATA_PATTERN, match =>
|
||||
'\n'.repeat(match.split('\n').length - 1))
|
||||
|
||||
const trimmedMap = trimmed.generateMap({ hires: 'boundary' })
|
||||
|
||||
@ -453,20 +611,26 @@ function excludeGeneratedCode(source: string | undefined, map: EncodedSourceMap)
|
||||
*/
|
||||
function findLongestFunctionLength(functions: Profiler.FunctionCoverage[]) {
|
||||
return functions.reduce((previous, current) => {
|
||||
const maxEndOffset = current.ranges.reduce((endOffset, range) => Math.max(endOffset, range.endOffset), 0)
|
||||
const maxEndOffset = current.ranges.reduce(
|
||||
(endOffset, range) => Math.max(endOffset, range.endOffset),
|
||||
0,
|
||||
)
|
||||
|
||||
return Math.max(previous, maxEndOffset)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function normalizeTransformResults(fetchCache: Map<string, { result: FetchResult }>) {
|
||||
function normalizeTransformResults(
|
||||
fetchCache: Map<string, { result: FetchResult }>,
|
||||
) {
|
||||
const normalized: TransformResults = new Map()
|
||||
|
||||
for (const [key, value] of fetchCache.entries()) {
|
||||
const cleanEntry = cleanUrl(key)
|
||||
|
||||
if (!normalized.has(cleanEntry))
|
||||
if (!normalized.has(cleanEntry)) {
|
||||
normalized.set(cleanEntry, value.result)
|
||||
}
|
||||
}
|
||||
|
||||
return normalized
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
/*
|
||||
* For details about the Profiler.* messages see https://chromedevtools.github.io/devtools-protocol/v8/Profiler/
|
||||
*/
|
||||
*/
|
||||
|
||||
import inspector from 'node:inspector'
|
||||
import type { Profiler } from 'node:inspector'
|
||||
@ -20,8 +20,9 @@ export function startCoverage() {
|
||||
export async function takeCoverage() {
|
||||
return new Promise((resolve, reject) => {
|
||||
session.post('Profiler.takePreciseCoverage', async (error, coverage) => {
|
||||
if (error)
|
||||
if (error) {
|
||||
return reject(error)
|
||||
}
|
||||
|
||||
// Reduce amount of data sent over rpc by doing some early result filtering
|
||||
const result = coverage.result.filter(filterResult)
|
||||
@ -29,8 +30,9 @@ export async function takeCoverage() {
|
||||
resolve({ result })
|
||||
})
|
||||
|
||||
if (provider === 'stackblitz')
|
||||
if (provider === 'stackblitz') {
|
||||
resolve({ result: [] })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -41,11 +43,13 @@ export function stopCoverage() {
|
||||
}
|
||||
|
||||
function filterResult(coverage: Profiler.ScriptCoverage): boolean {
|
||||
if (!coverage.url.startsWith('file://'))
|
||||
if (!coverage.url.startsWith('file://')) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (coverage.url.includes('/node_modules/'))
|
||||
if (coverage.url.includes('/node_modules/')) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@ -6,7 +6,11 @@ Jest's expect matchers as a Chai plugin.
|
||||
|
||||
```js
|
||||
import * as chai from 'chai'
|
||||
import { JestAsymmetricMatchers, JestChaiExpect, JestExtend } from '@vitest/expect'
|
||||
import {
|
||||
JestAsymmetricMatchers,
|
||||
JestChaiExpect,
|
||||
JestExtend,
|
||||
} from '@vitest/expect'
|
||||
|
||||
// allows using expect.extend instead of chai.use to extend plugins
|
||||
chai.use(JestExtend)
|
||||
|
||||
@ -20,7 +20,11 @@ const plugins = [
|
||||
}),
|
||||
copy({
|
||||
targets: [
|
||||
{ src: 'node_modules/@types/chai/index.d.ts', dest: 'dist', rename: 'chai.d.cts' },
|
||||
{
|
||||
src: 'node_modules/@types/chai/index.d.ts',
|
||||
dest: 'dist',
|
||||
rename: 'chai.d.cts',
|
||||
},
|
||||
],
|
||||
}),
|
||||
]
|
||||
@ -46,15 +50,14 @@ export default defineConfig([
|
||||
format: 'esm',
|
||||
},
|
||||
external,
|
||||
plugins: [
|
||||
dts({ respectExternal: true }),
|
||||
],
|
||||
plugins: [dts({ respectExternal: true })],
|
||||
onwarn,
|
||||
},
|
||||
])
|
||||
|
||||
function onwarn(message) {
|
||||
if (['EMPTY_BUNDLE', 'CIRCULAR_DEPENDENCY'].includes(message.code))
|
||||
if (['EMPTY_BUNDLE', 'CIRCULAR_DEPENDENCY'].includes(message.code)) {
|
||||
return
|
||||
}
|
||||
console.error(message)
|
||||
}
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
export const MATCHERS_OBJECT = Symbol.for('matchers-object')
|
||||
export const JEST_MATCHERS_OBJECT = Symbol.for('$$jest-matchers-object')
|
||||
export const GLOBAL_EXPECT = Symbol.for('expect-global')
|
||||
export const ASYMMETRIC_MATCHERS_OBJECT = Symbol.for('asymmetric-matchers-object')
|
||||
export const ASYMMETRIC_MATCHERS_OBJECT = Symbol.for(
|
||||
'asymmetric-matchers-object',
|
||||
)
|
||||
|
||||
@ -1,9 +1,20 @@
|
||||
import type { ChaiPlugin, MatcherState } from './types'
|
||||
import { GLOBAL_EXPECT } from './constants'
|
||||
import { getState } from './state'
|
||||
import { diff, getCustomEqualityTesters, getMatcherUtils, stringify } from './jest-matcher-utils'
|
||||
import {
|
||||
diff,
|
||||
getCustomEqualityTesters,
|
||||
getMatcherUtils,
|
||||
stringify,
|
||||
} from './jest-matcher-utils'
|
||||
|
||||
import { equals, isA, iterableEquality, pluralize, subsetEquality } from './jest-utils'
|
||||
import {
|
||||
equals,
|
||||
isA,
|
||||
iterableEquality,
|
||||
pluralize,
|
||||
subsetEquality,
|
||||
} from './jest-utils'
|
||||
|
||||
export interface AsymmetricMatcherInterface {
|
||||
asymmetricMatch: (other: unknown) => boolean
|
||||
@ -40,23 +51,25 @@ export abstract class AsymmetricMatcher<
|
||||
abstract asymmetricMatch(other: unknown): boolean
|
||||
abstract toString(): string
|
||||
getExpectedType?(): string
|
||||
toAsymmetricMatcher?(): string
|
||||
toAsymmetricMatcher?(): string;
|
||||
|
||||
// implement custom chai/loupe inspect for better AssertionError.message formatting
|
||||
// https://github.com/chaijs/loupe/blob/9b8a6deabcd50adc056a64fb705896194710c5c6/src/index.ts#L29
|
||||
[Symbol.for('chai/inspect')](options: { depth: number; truncate: number }) {
|
||||
// minimal pretty-format with simple manual truncation
|
||||
const result = stringify(this, options.depth, { min: true })
|
||||
if (result.length <= options.truncate)
|
||||
if (result.length <= options.truncate) {
|
||||
return result
|
||||
}
|
||||
return `${this.toString()}{…}`
|
||||
}
|
||||
}
|
||||
|
||||
export class StringContaining extends AsymmetricMatcher<string> {
|
||||
constructor(sample: string, inverse = false) {
|
||||
if (!isA('String', sample))
|
||||
if (!isA('String', sample)) {
|
||||
throw new Error('Expected is not a string')
|
||||
}
|
||||
|
||||
super(sample, inverse)
|
||||
}
|
||||
@ -90,27 +103,33 @@ export class Anything extends AsymmetricMatcher<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export class ObjectContaining extends AsymmetricMatcher<Record<string, unknown>> {
|
||||
export class ObjectContaining extends AsymmetricMatcher<
|
||||
Record<string, unknown>
|
||||
> {
|
||||
constructor(sample: Record<string, unknown>, inverse = false) {
|
||||
super(sample, inverse)
|
||||
}
|
||||
|
||||
getPrototype(obj: object) {
|
||||
if (Object.getPrototypeOf)
|
||||
if (Object.getPrototypeOf) {
|
||||
return Object.getPrototypeOf(obj)
|
||||
}
|
||||
|
||||
if (obj.constructor.prototype === obj)
|
||||
if (obj.constructor.prototype === obj) {
|
||||
return null
|
||||
}
|
||||
|
||||
return obj.constructor.prototype
|
||||
}
|
||||
|
||||
hasProperty(obj: object | null, property: string): boolean {
|
||||
if (!obj)
|
||||
if (!obj) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(obj, property))
|
||||
if (Object.prototype.hasOwnProperty.call(obj, property)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return this.hasProperty(this.getPrototype(obj), property)
|
||||
}
|
||||
@ -118,9 +137,8 @@ export class ObjectContaining extends AsymmetricMatcher<Record<string, unknown>>
|
||||
asymmetricMatch(other: any) {
|
||||
if (typeof this.sample !== 'object') {
|
||||
throw new TypeError(
|
||||
`You must provide an object to ${this.toString()}, not '${
|
||||
typeof this.sample
|
||||
}'.`,
|
||||
`You must provide an object to ${this.toString()}, not '${typeof this
|
||||
.sample}'.`,
|
||||
)
|
||||
}
|
||||
|
||||
@ -128,7 +146,14 @@ export class ObjectContaining extends AsymmetricMatcher<Record<string, unknown>>
|
||||
|
||||
const matcherContext = this.getMatcherContext()
|
||||
for (const property in this.sample) {
|
||||
if (!this.hasProperty(other, property) || !equals(this.sample[property], other[property], matcherContext.customTesters)) {
|
||||
if (
|
||||
!this.hasProperty(other, property)
|
||||
|| !equals(
|
||||
this.sample[property],
|
||||
other[property],
|
||||
matcherContext.customTesters,
|
||||
)
|
||||
) {
|
||||
result = false
|
||||
break
|
||||
}
|
||||
@ -154,9 +179,8 @@ export class ArrayContaining<T = unknown> extends AsymmetricMatcher<Array<T>> {
|
||||
asymmetricMatch(other: Array<T>) {
|
||||
if (!Array.isArray(this.sample)) {
|
||||
throw new TypeError(
|
||||
`You must provide an array to ${this.toString()}, not '${
|
||||
typeof this.sample
|
||||
}'.`,
|
||||
`You must provide an array to ${this.toString()}, not '${typeof this
|
||||
.sample}'.`,
|
||||
)
|
||||
}
|
||||
|
||||
@ -165,7 +189,9 @@ export class ArrayContaining<T = unknown> extends AsymmetricMatcher<Array<T>> {
|
||||
= this.sample.length === 0
|
||||
|| (Array.isArray(other)
|
||||
&& this.sample.every(item =>
|
||||
other.some(another => equals(item, another, matcherContext.customTesters)),
|
||||
other.some(another =>
|
||||
equals(item, another, matcherContext.customTesters),
|
||||
),
|
||||
))
|
||||
|
||||
return this.inverse ? !result : result
|
||||
@ -192,8 +218,9 @@ export class Any extends AsymmetricMatcher<any> {
|
||||
}
|
||||
|
||||
fnNameFor(func: Function) {
|
||||
if (func.name)
|
||||
if (func.name) {
|
||||
return func.name
|
||||
}
|
||||
|
||||
const functionToString = Function.prototype.toString
|
||||
|
||||
@ -204,26 +231,33 @@ export class Any extends AsymmetricMatcher<any> {
|
||||
}
|
||||
|
||||
asymmetricMatch(other: unknown) {
|
||||
if (this.sample === String)
|
||||
if (this.sample === String) {
|
||||
return typeof other == 'string' || other instanceof String
|
||||
}
|
||||
|
||||
if (this.sample === Number)
|
||||
if (this.sample === Number) {
|
||||
return typeof other == 'number' || other instanceof Number
|
||||
}
|
||||
|
||||
if (this.sample === Function)
|
||||
if (this.sample === Function) {
|
||||
return typeof other == 'function' || other instanceof Function
|
||||
}
|
||||
|
||||
if (this.sample === Boolean)
|
||||
if (this.sample === Boolean) {
|
||||
return typeof other == 'boolean' || other instanceof Boolean
|
||||
}
|
||||
|
||||
if (this.sample === BigInt)
|
||||
if (this.sample === BigInt) {
|
||||
return typeof other == 'bigint' || other instanceof BigInt
|
||||
}
|
||||
|
||||
if (this.sample === Symbol)
|
||||
if (this.sample === Symbol) {
|
||||
return typeof other == 'symbol' || other instanceof Symbol
|
||||
}
|
||||
|
||||
if (this.sample === Object)
|
||||
if (this.sample === Object) {
|
||||
return typeof other == 'object'
|
||||
}
|
||||
|
||||
return other instanceof this.sample
|
||||
}
|
||||
@ -233,20 +267,25 @@ export class Any extends AsymmetricMatcher<any> {
|
||||
}
|
||||
|
||||
getExpectedType() {
|
||||
if (this.sample === String)
|
||||
if (this.sample === String) {
|
||||
return 'string'
|
||||
}
|
||||
|
||||
if (this.sample === Number)
|
||||
if (this.sample === Number) {
|
||||
return 'number'
|
||||
}
|
||||
|
||||
if (this.sample === Function)
|
||||
if (this.sample === Function) {
|
||||
return 'function'
|
||||
}
|
||||
|
||||
if (this.sample === Object)
|
||||
if (this.sample === Object) {
|
||||
return 'object'
|
||||
}
|
||||
|
||||
if (this.sample === Boolean)
|
||||
if (this.sample === Boolean) {
|
||||
return 'boolean'
|
||||
}
|
||||
|
||||
return this.fnNameFor(this.sample)
|
||||
}
|
||||
@ -258,8 +297,9 @@ export class Any extends AsymmetricMatcher<any> {
|
||||
|
||||
export class StringMatching extends AsymmetricMatcher<RegExp> {
|
||||
constructor(sample: string | RegExp, inverse = false) {
|
||||
if (!isA('String', sample) && !isA('RegExp', sample))
|
||||
if (!isA('String', sample) && !isA('RegExp', sample)) {
|
||||
throw new Error('Expected is not a String or a RegExp')
|
||||
}
|
||||
|
||||
super(new RegExp(sample), inverse)
|
||||
}
|
||||
@ -283,11 +323,13 @@ class CloseTo extends AsymmetricMatcher<number> {
|
||||
private readonly precision: number
|
||||
|
||||
constructor(sample: number, precision = 2, inverse = false) {
|
||||
if (!isA('Number', sample))
|
||||
if (!isA('Number', sample)) {
|
||||
throw new Error('Expected is not a Number')
|
||||
}
|
||||
|
||||
if (!isA('Number', precision))
|
||||
if (!isA('Number', precision)) {
|
||||
throw new Error('Precision is not a Number')
|
||||
}
|
||||
|
||||
super(sample)
|
||||
this.inverse = inverse
|
||||
@ -295,19 +337,25 @@ class CloseTo extends AsymmetricMatcher<number> {
|
||||
}
|
||||
|
||||
asymmetricMatch(other: number) {
|
||||
if (!isA('Number', other))
|
||||
if (!isA('Number', other)) {
|
||||
return false
|
||||
}
|
||||
|
||||
let result = false
|
||||
if (other === Number.POSITIVE_INFINITY && this.sample === Number.POSITIVE_INFINITY) {
|
||||
if (
|
||||
other === Number.POSITIVE_INFINITY
|
||||
&& this.sample === Number.POSITIVE_INFINITY
|
||||
) {
|
||||
result = true // Infinity - Infinity is NaN
|
||||
}
|
||||
else if (other === Number.NEGATIVE_INFINITY && this.sample === Number.NEGATIVE_INFINITY) {
|
||||
else if (
|
||||
other === Number.NEGATIVE_INFINITY
|
||||
&& this.sample === Number.NEGATIVE_INFINITY
|
||||
) {
|
||||
result = true // -Infinity - -Infinity is NaN
|
||||
}
|
||||
else {
|
||||
result
|
||||
= Math.abs(this.sample - other) < 10 ** -this.precision / 2
|
||||
result = Math.abs(this.sample - other) < 10 ** -this.precision / 2
|
||||
}
|
||||
return this.inverse ? !result : result
|
||||
}
|
||||
@ -330,17 +378,9 @@ class CloseTo extends AsymmetricMatcher<number> {
|
||||
}
|
||||
|
||||
export const JestAsymmetricMatchers: ChaiPlugin = (chai, utils) => {
|
||||
utils.addMethod(
|
||||
chai.expect,
|
||||
'anything',
|
||||
() => new Anything(),
|
||||
)
|
||||
utils.addMethod(chai.expect, 'anything', () => new Anything())
|
||||
|
||||
utils.addMethod(
|
||||
chai.expect,
|
||||
'any',
|
||||
(expected: unknown) => new Any(expected),
|
||||
)
|
||||
utils.addMethod(chai.expect, 'any', (expected: unknown) => new Any(expected))
|
||||
|
||||
utils.addMethod(
|
||||
chai.expect,
|
||||
@ -370,14 +410,18 @@ export const JestAsymmetricMatchers: ChaiPlugin = (chai, utils) => {
|
||||
chai.expect,
|
||||
'closeTo',
|
||||
(expected: any, precision?: number) => new CloseTo(expected, precision),
|
||||
)
|
||||
);
|
||||
|
||||
// defineProperty does not work
|
||||
;(chai.expect as any).not = {
|
||||
stringContaining: (expected: string) => new StringContaining(expected, true),
|
||||
(chai.expect as any).not = {
|
||||
stringContaining: (expected: string) =>
|
||||
new StringContaining(expected, true),
|
||||
objectContaining: (expected: any) => new ObjectContaining(expected, true),
|
||||
arrayContaining: <T = unknown>(expected: Array<T>) => new ArrayContaining<T>(expected, true),
|
||||
stringMatching: (expected: string | RegExp) => new StringMatching(expected, true),
|
||||
closeTo: (expected: any, precision?: number) => new CloseTo(expected, precision, true),
|
||||
arrayContaining: <T = unknown>(expected: Array<T>) =>
|
||||
new ArrayContaining<T>(expected, true),
|
||||
stringMatching: (expected: string | RegExp) =>
|
||||
new StringMatching(expected, true),
|
||||
closeTo: (expected: any, precision?: number) =>
|
||||
new CloseTo(expected, precision, true),
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -10,16 +10,20 @@ import { ASYMMETRIC_MATCHERS_OBJECT, JEST_MATCHERS_OBJECT } from './constants'
|
||||
import { AsymmetricMatcher } from './jest-asymmetric-matchers'
|
||||
import { getState } from './state'
|
||||
|
||||
import { diff, getCustomEqualityTesters, getMatcherUtils, stringify } from './jest-matcher-utils'
|
||||
|
||||
import {
|
||||
equals,
|
||||
iterableEquality,
|
||||
subsetEquality,
|
||||
} from './jest-utils'
|
||||
diff,
|
||||
getCustomEqualityTesters,
|
||||
getMatcherUtils,
|
||||
stringify,
|
||||
} from './jest-matcher-utils'
|
||||
|
||||
import { equals, iterableEquality, subsetEquality } from './jest-utils'
|
||||
import { wrapSoft } from './utils'
|
||||
|
||||
function getMatcherState(assertion: Chai.AssertionStatic & Chai.Assertion, expect: ExpectStatic) {
|
||||
function getMatcherState(
|
||||
assertion: Chai.AssertionStatic & Chai.Assertion,
|
||||
expect: ExpectStatic,
|
||||
) {
|
||||
const obj = assertion._obj
|
||||
const isNot = util.flag(assertion, 'negate') as boolean
|
||||
const promise = util.flag(assertion, 'promise') || ''
|
||||
@ -57,90 +61,123 @@ class JestExtendError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function JestExtendPlugin(c: Chai.ChaiStatic, expect: ExpectStatic, matchers: MatchersObject): ChaiPlugin {
|
||||
function JestExtendPlugin(
|
||||
c: Chai.ChaiStatic,
|
||||
expect: ExpectStatic,
|
||||
matchers: MatchersObject,
|
||||
): ChaiPlugin {
|
||||
return (_, utils) => {
|
||||
Object.entries(matchers).forEach(([expectAssertionName, expectAssertion]) => {
|
||||
function expectWrapper(this: Chai.AssertionStatic & Chai.Assertion, ...args: any[]) {
|
||||
const { state, isNot, obj } = getMatcherState(this, expect)
|
||||
Object.entries(matchers).forEach(
|
||||
([expectAssertionName, expectAssertion]) => {
|
||||
function expectWrapper(
|
||||
this: Chai.AssertionStatic & Chai.Assertion,
|
||||
...args: any[]
|
||||
) {
|
||||
const { state, isNot, obj } = getMatcherState(this, expect)
|
||||
|
||||
// @ts-expect-error args wanting tuple
|
||||
const result = expectAssertion.call(state, obj, ...args)
|
||||
// @ts-expect-error args wanting tuple
|
||||
const result = expectAssertion.call(state, obj, ...args)
|
||||
|
||||
if (result && typeof result === 'object' && result instanceof Promise) {
|
||||
return result.then(({ pass, message, actual, expected }) => {
|
||||
if ((pass && isNot) || (!pass && !isNot))
|
||||
throw new JestExtendError(message(), actual, expected)
|
||||
})
|
||||
if (
|
||||
result
|
||||
&& typeof result === 'object'
|
||||
&& result instanceof Promise
|
||||
) {
|
||||
return result.then(({ pass, message, actual, expected }) => {
|
||||
if ((pass && isNot) || (!pass && !isNot)) {
|
||||
throw new JestExtendError(message(), actual, expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const { pass, message, actual, expected } = result
|
||||
|
||||
if ((pass && isNot) || (!pass && !isNot)) {
|
||||
throw new JestExtendError(message(), actual, expected)
|
||||
}
|
||||
}
|
||||
|
||||
const { pass, message, actual, expected } = result
|
||||
const softWrapper = wrapSoft(utils, expectWrapper)
|
||||
utils.addMethod(
|
||||
(globalThis as any)[JEST_MATCHERS_OBJECT].matchers,
|
||||
expectAssertionName,
|
||||
softWrapper,
|
||||
)
|
||||
utils.addMethod(
|
||||
c.Assertion.prototype,
|
||||
expectAssertionName,
|
||||
softWrapper,
|
||||
)
|
||||
|
||||
if ((pass && isNot) || (!pass && !isNot))
|
||||
throw new JestExtendError(message(), actual, expected)
|
||||
}
|
||||
class CustomMatcher extends AsymmetricMatcher<[unknown, ...unknown[]]> {
|
||||
constructor(inverse = false, ...sample: [unknown, ...unknown[]]) {
|
||||
super(sample, inverse)
|
||||
}
|
||||
|
||||
const softWrapper = wrapSoft(utils, expectWrapper)
|
||||
utils.addMethod((globalThis as any)[JEST_MATCHERS_OBJECT].matchers, expectAssertionName, softWrapper)
|
||||
utils.addMethod(c.Assertion.prototype, expectAssertionName, softWrapper)
|
||||
asymmetricMatch(other: unknown) {
|
||||
const { pass } = expectAssertion.call(
|
||||
this.getMatcherContext(expect),
|
||||
other,
|
||||
...this.sample,
|
||||
) as SyncExpectationResult
|
||||
|
||||
class CustomMatcher extends AsymmetricMatcher<[unknown, ...unknown[]]> {
|
||||
constructor(inverse = false, ...sample: [unknown, ...unknown[]]) {
|
||||
super(sample, inverse)
|
||||
return this.inverse ? !pass : pass
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `${this.inverse ? 'not.' : ''}${expectAssertionName}`
|
||||
}
|
||||
|
||||
getExpectedType() {
|
||||
return 'any'
|
||||
}
|
||||
|
||||
toAsymmetricMatcher() {
|
||||
return `${this.toString()}<${this.sample.map(String).join(', ')}>`
|
||||
}
|
||||
}
|
||||
|
||||
asymmetricMatch(other: unknown) {
|
||||
const { pass } = expectAssertion.call(
|
||||
this.getMatcherContext(expect),
|
||||
other,
|
||||
...this.sample,
|
||||
) as SyncExpectationResult
|
||||
const customMatcher = (...sample: [unknown, ...unknown[]]) =>
|
||||
new CustomMatcher(false, ...sample)
|
||||
|
||||
return this.inverse ? !pass : pass
|
||||
}
|
||||
Object.defineProperty(expect, expectAssertionName, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: customMatcher,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
toString() {
|
||||
return `${this.inverse ? 'not.' : ''}${expectAssertionName}`
|
||||
}
|
||||
Object.defineProperty(expect.not, expectAssertionName, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: (...sample: [unknown, ...unknown[]]) =>
|
||||
new CustomMatcher(true, ...sample),
|
||||
writable: true,
|
||||
})
|
||||
|
||||
getExpectedType() {
|
||||
return 'any'
|
||||
}
|
||||
|
||||
toAsymmetricMatcher() {
|
||||
return `${this.toString()}<${this.sample.map(String).join(', ')}>`
|
||||
}
|
||||
}
|
||||
|
||||
const customMatcher = (...sample: [unknown, ...unknown[]]) => new CustomMatcher(false, ...sample)
|
||||
|
||||
Object.defineProperty(expect, expectAssertionName, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: customMatcher,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
Object.defineProperty(expect.not, expectAssertionName, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: (...sample: [unknown, ...unknown[]]) => new CustomMatcher(true, ...sample),
|
||||
writable: true,
|
||||
})
|
||||
|
||||
// keep track of asymmetric matchers on global so that it can be copied over to local context's `expect`.
|
||||
// note that the negated variant is automatically shared since it's assigned on the single `expect.not` object.
|
||||
Object.defineProperty(((globalThis as any)[ASYMMETRIC_MATCHERS_OBJECT]), expectAssertionName, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: customMatcher,
|
||||
writable: true,
|
||||
})
|
||||
})
|
||||
// keep track of asymmetric matchers on global so that it can be copied over to local context's `expect`.
|
||||
// note that the negated variant is automatically shared since it's assigned on the single `expect.not` object.
|
||||
Object.defineProperty(
|
||||
(globalThis as any)[ASYMMETRIC_MATCHERS_OBJECT],
|
||||
expectAssertionName,
|
||||
{
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: customMatcher,
|
||||
writable: true,
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const JestExtend: ChaiPlugin = (chai, utils) => {
|
||||
utils.addMethod(chai.expect, 'extend', (expect: ExpectStatic, expects: MatchersObject) => {
|
||||
use(JestExtendPlugin(chai, expect, expects))
|
||||
})
|
||||
utils.addMethod(
|
||||
chai.expect,
|
||||
'extend',
|
||||
(expect: ExpectStatic, expects: MatchersObject) => {
|
||||
use(JestExtendPlugin(chai, expect, expects))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@ -49,12 +49,12 @@ export function getMatcherUtils() {
|
||||
}
|
||||
|
||||
if (matcherName.includes('.')) {
|
||||
// Old format: for backward compatibility,
|
||||
// especially without promise or isNot options
|
||||
// Old format: for backward compatibility,
|
||||
// especially without promise or isNot options
|
||||
dimString += matcherName
|
||||
}
|
||||
else {
|
||||
// New format: omit period from matcherName arg
|
||||
// New format: omit period from matcherName arg
|
||||
hint += DIM_COLOR(`${dimString}.`) + matcherName
|
||||
dimString = ''
|
||||
}
|
||||
@ -64,16 +64,19 @@ export function getMatcherUtils() {
|
||||
}
|
||||
else {
|
||||
hint += DIM_COLOR(`${dimString}(`) + expectedColor(expected)
|
||||
if (secondArgument)
|
||||
if (secondArgument) {
|
||||
hint += DIM_COLOR(', ') + secondArgumentColor(secondArgument)
|
||||
}
|
||||
dimString = ')'
|
||||
}
|
||||
|
||||
if (comment !== '')
|
||||
if (comment !== '') {
|
||||
dimString += ` // ${comment}`
|
||||
}
|
||||
|
||||
if (dimString !== '')
|
||||
if (dimString !== '') {
|
||||
hint += DIM_COLOR(dimString)
|
||||
}
|
||||
|
||||
return hint
|
||||
}
|
||||
|
||||
@ -39,21 +39,31 @@ export function equals(
|
||||
const functionToString = Function.prototype.toString
|
||||
|
||||
export function isAsymmetric(obj: any) {
|
||||
return !!obj && typeof obj === 'object' && 'asymmetricMatch' in obj && isA('Function', obj.asymmetricMatch)
|
||||
return (
|
||||
!!obj
|
||||
&& typeof obj === 'object'
|
||||
&& 'asymmetricMatch' in obj
|
||||
&& isA('Function', obj.asymmetricMatch)
|
||||
)
|
||||
}
|
||||
|
||||
export function hasAsymmetric(obj: any, seen = new Set()): boolean {
|
||||
if (seen.has(obj))
|
||||
if (seen.has(obj)) {
|
||||
return false
|
||||
}
|
||||
seen.add(obj)
|
||||
if (isAsymmetric(obj))
|
||||
if (isAsymmetric(obj)) {
|
||||
return true
|
||||
if (Array.isArray(obj))
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.some(i => hasAsymmetric(i, seen))
|
||||
if (obj instanceof Set)
|
||||
}
|
||||
if (obj instanceof Set) {
|
||||
return Array.from(obj).some(i => hasAsymmetric(i, seen))
|
||||
if (isObject(obj))
|
||||
}
|
||||
if (isObject(obj)) {
|
||||
return Object.values(obj).some(v => hasAsymmetric(v, seen))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@ -61,14 +71,17 @@ function asymmetricMatch(a: any, b: any) {
|
||||
const asymmetricA = isAsymmetric(a)
|
||||
const asymmetricB = isAsymmetric(b)
|
||||
|
||||
if (asymmetricA && asymmetricB)
|
||||
if (asymmetricA && asymmetricB) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (asymmetricA)
|
||||
if (asymmetricA) {
|
||||
return a.asymmetricMatch(b)
|
||||
}
|
||||
|
||||
if (asymmetricB)
|
||||
if (asymmetricB) {
|
||||
return b.asymmetricMatch(a)
|
||||
}
|
||||
}
|
||||
|
||||
// Equality function lovingly adapted from isEqual in
|
||||
@ -84,32 +97,44 @@ function eq(
|
||||
let result = true
|
||||
|
||||
const asymmetricResult = asymmetricMatch(a, b)
|
||||
if (asymmetricResult !== undefined)
|
||||
if (asymmetricResult !== undefined) {
|
||||
return asymmetricResult
|
||||
}
|
||||
|
||||
const testerContext: TesterContext = { equals }
|
||||
for (let i = 0; i < customTesters.length; i++) {
|
||||
const customTesterResult = customTesters[i].call(testerContext, a, b, customTesters)
|
||||
if (customTesterResult !== undefined)
|
||||
const customTesterResult = customTesters[i].call(
|
||||
testerContext,
|
||||
a,
|
||||
b,
|
||||
customTesters,
|
||||
)
|
||||
if (customTesterResult !== undefined) {
|
||||
return customTesterResult
|
||||
}
|
||||
}
|
||||
|
||||
if (a instanceof Error && b instanceof Error)
|
||||
if (a instanceof Error && b instanceof Error) {
|
||||
return a.message === b.message
|
||||
}
|
||||
|
||||
if (typeof URL === 'function' && a instanceof URL && b instanceof URL)
|
||||
if (typeof URL === 'function' && a instanceof URL && b instanceof URL) {
|
||||
return a.href === b.href
|
||||
}
|
||||
|
||||
if (Object.is(a, b))
|
||||
if (Object.is(a, b)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// A strict comparison is necessary because `null == undefined`.
|
||||
if (a === null || b === null)
|
||||
if (a === null || b === null) {
|
||||
return a === b
|
||||
}
|
||||
|
||||
const className = Object.prototype.toString.call(a)
|
||||
if (className !== Object.prototype.toString.call(b))
|
||||
if (className !== Object.prototype.toString.call(b)) {
|
||||
return false
|
||||
}
|
||||
|
||||
switch (className) {
|
||||
case '[object Boolean]':
|
||||
@ -133,18 +158,20 @@ function eq(
|
||||
// Coerce dates to numeric primitive values. Dates are compared by their
|
||||
// millisecond representations. Note that invalid dates with millisecond representations
|
||||
// of `NaN` are equivalent.
|
||||
return (numA === numB) || (Number.isNaN(numA) && Number.isNaN(numB))
|
||||
return numA === numB || (Number.isNaN(numA) && Number.isNaN(numB))
|
||||
}
|
||||
// RegExps are compared by their source patterns and flags.
|
||||
case '[object RegExp]':
|
||||
return a.source === b.source && a.flags === b.flags
|
||||
}
|
||||
if (typeof a !== 'object' || typeof b !== 'object')
|
||||
if (typeof a !== 'object' || typeof b !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
// Use DOM3 method isEqualNode (IE>=9)
|
||||
if (isDomNode(a) && isDomNode(b))
|
||||
if (isDomNode(a) && isDomNode(b)) {
|
||||
return a.isEqualNode(b)
|
||||
}
|
||||
|
||||
// Used to detect circular references.
|
||||
let length = aStack.length
|
||||
@ -153,19 +180,21 @@ function eq(
|
||||
// unique nested structures.
|
||||
// circular references at same depth are equal
|
||||
// circular reference is not equal to non-circular one
|
||||
if (aStack[length] === a)
|
||||
if (aStack[length] === a) {
|
||||
return bStack[length] === b
|
||||
|
||||
else if (bStack[length] === b)
|
||||
}
|
||||
else if (bStack[length] === b) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Add the first object to the stack of traversed objects.
|
||||
aStack.push(a)
|
||||
bStack.push(b)
|
||||
// Recursively compare objects and arrays.
|
||||
// Compare array lengths to determine if a deep comparison is necessary.
|
||||
if (className === '[object Array]' && a.length !== b.length)
|
||||
if (className === '[object Array]' && a.length !== b.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Deep compare objects.
|
||||
const aKeys = keys(a, hasKey)
|
||||
@ -173,8 +202,9 @@ function eq(
|
||||
let size = aKeys.length
|
||||
|
||||
// Ensure that both objects contain the same number of properties before comparing deep equality.
|
||||
if (keys(b, hasKey).length !== size)
|
||||
if (keys(b, hasKey).length !== size) {
|
||||
return false
|
||||
}
|
||||
|
||||
while (size--) {
|
||||
key = aKeys[size]
|
||||
@ -184,8 +214,9 @@ function eq(
|
||||
= hasKey(b, key)
|
||||
&& eq(a[key], b[key], aStack, bStack, customTesters, hasKey)
|
||||
|
||||
if (!result)
|
||||
if (!result) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Remove the first object from the stack of traversed objects.
|
||||
aStack.pop()
|
||||
@ -198,8 +229,9 @@ function keys(obj: object, hasKey: (obj: object, key: string) => boolean) {
|
||||
const keys = []
|
||||
|
||||
for (const key in obj) {
|
||||
if (hasKey(obj, key))
|
||||
if (hasKey(obj, key)) {
|
||||
keys.push(key)
|
||||
}
|
||||
}
|
||||
return keys.concat(
|
||||
(Object.getOwnPropertySymbols(obj) as Array<any>).filter(
|
||||
@ -236,8 +268,9 @@ function isDomNode(obj: any): boolean {
|
||||
}
|
||||
|
||||
export function fnNameFor(func: Function) {
|
||||
if (func.name)
|
||||
if (func.name) {
|
||||
return func.name
|
||||
}
|
||||
|
||||
const matches = functionToString
|
||||
.call(func)
|
||||
@ -246,21 +279,25 @@ export function fnNameFor(func: Function) {
|
||||
}
|
||||
|
||||
function getPrototype(obj: object) {
|
||||
if (Object.getPrototypeOf)
|
||||
if (Object.getPrototypeOf) {
|
||||
return Object.getPrototypeOf(obj)
|
||||
}
|
||||
|
||||
if (obj.constructor.prototype === obj)
|
||||
if (obj.constructor.prototype === obj) {
|
||||
return null
|
||||
}
|
||||
|
||||
return obj.constructor.prototype
|
||||
}
|
||||
|
||||
export function hasProperty(obj: object | null, property: string): boolean {
|
||||
if (!obj)
|
||||
if (!obj) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(obj, property))
|
||||
if (Object.prototype.hasOwnProperty.call(obj, property)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return hasProperty(getPrototype(obj), property)
|
||||
}
|
||||
@ -331,7 +368,13 @@ function hasIterator(object: any) {
|
||||
return !!(object != null && object[IteratorSymbol])
|
||||
}
|
||||
|
||||
export function iterableEquality(a: any, b: any, customTesters: Array<Tester> = [], aStack: Array<any> = [], bStack: Array<any> = []): boolean | undefined {
|
||||
export function iterableEquality(
|
||||
a: any,
|
||||
b: any,
|
||||
customTesters: Array<Tester> = [],
|
||||
aStack: Array<any> = [],
|
||||
bStack: Array<any> = [],
|
||||
): boolean | undefined {
|
||||
if (
|
||||
typeof a !== 'object'
|
||||
|| typeof b !== 'object'
|
||||
@ -343,8 +386,9 @@ export function iterableEquality(a: any, b: any, customTesters: Array<Tester> =
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (a.constructor !== b.constructor)
|
||||
if (a.constructor !== b.constructor) {
|
||||
return false
|
||||
}
|
||||
|
||||
let length = aStack.length
|
||||
while (length--) {
|
||||
@ -352,8 +396,9 @@ export function iterableEquality(a: any, b: any, customTesters: Array<Tester> =
|
||||
// unique nested structures.
|
||||
// circular references at same depth are equal
|
||||
// circular reference is not equal to non-circular one
|
||||
if (aStack[length] === a)
|
||||
if (aStack[length] === a) {
|
||||
return bStack[length] === b
|
||||
}
|
||||
}
|
||||
aStack.push(a)
|
||||
bStack.push(b)
|
||||
@ -364,13 +409,7 @@ export function iterableEquality(a: any, b: any, customTesters: Array<Tester> =
|
||||
]
|
||||
|
||||
function iterableEqualityWithStack(a: any, b: any) {
|
||||
return iterableEquality(
|
||||
a,
|
||||
b,
|
||||
[...customTesters],
|
||||
[...aStack],
|
||||
[...bStack],
|
||||
)
|
||||
return iterableEquality(a, b, [...customTesters], [...aStack], [...bStack])
|
||||
}
|
||||
|
||||
if (a.size !== undefined) {
|
||||
@ -384,8 +423,9 @@ export function iterableEquality(a: any, b: any, customTesters: Array<Tester> =
|
||||
let has = false
|
||||
for (const bValue of b) {
|
||||
const isEqual = equals(aValue, bValue, filteredCustomTesters)
|
||||
if (isEqual === true)
|
||||
if (isEqual === true) {
|
||||
has = true
|
||||
}
|
||||
}
|
||||
|
||||
if (has === false) {
|
||||
@ -408,14 +448,24 @@ export function iterableEquality(a: any, b: any, customTesters: Array<Tester> =
|
||||
) {
|
||||
let has = false
|
||||
for (const bEntry of b) {
|
||||
const matchedKey = equals(aEntry[0], bEntry[0], filteredCustomTesters)
|
||||
const matchedKey = equals(
|
||||
aEntry[0],
|
||||
bEntry[0],
|
||||
filteredCustomTesters,
|
||||
)
|
||||
|
||||
let matchedValue = false
|
||||
if (matchedKey === true)
|
||||
matchedValue = equals(aEntry[1], bEntry[1], filteredCustomTesters)
|
||||
if (matchedKey === true) {
|
||||
matchedValue = equals(
|
||||
aEntry[1],
|
||||
bEntry[1],
|
||||
filteredCustomTesters,
|
||||
)
|
||||
}
|
||||
|
||||
if (matchedValue === true)
|
||||
if (matchedValue === true) {
|
||||
has = true
|
||||
}
|
||||
}
|
||||
|
||||
if (has === false) {
|
||||
@ -435,15 +485,13 @@ export function iterableEquality(a: any, b: any, customTesters: Array<Tester> =
|
||||
|
||||
for (const aValue of a) {
|
||||
const nextB = bIterator.next()
|
||||
if (
|
||||
nextB.done
|
||||
|| !equals(aValue, nextB.value, filteredCustomTesters)
|
||||
) {
|
||||
if (nextB.done || !equals(aValue, nextB.value, filteredCustomTesters)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (!bIterator.next().done)
|
||||
if (!bIterator.next().done) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
!isImmutableList(a)
|
||||
@ -453,8 +501,9 @@ export function iterableEquality(a: any, b: any, customTesters: Array<Tester> =
|
||||
) {
|
||||
const aEntries = Object.entries(a)
|
||||
const bEntries = Object.entries(b)
|
||||
if (!equals(aEntries, bEntries))
|
||||
if (!equals(aEntries, bEntries)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the first value from the stack of traversed values.
|
||||
@ -470,8 +519,9 @@ function hasPropertyInObject(object: object, key: string | symbol): boolean {
|
||||
const shouldTerminate
|
||||
= !object || typeof object !== 'object' || object === Object.prototype
|
||||
|
||||
if (shouldTerminate)
|
||||
if (shouldTerminate) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
Object.prototype.hasOwnProperty.call(object, key)
|
||||
@ -480,37 +530,47 @@ function hasPropertyInObject(object: object, key: string | symbol): boolean {
|
||||
}
|
||||
|
||||
function isObjectWithKeys(a: any) {
|
||||
return isObject(a)
|
||||
return (
|
||||
isObject(a)
|
||||
&& !(a instanceof Error)
|
||||
&& !(Array.isArray(a))
|
||||
&& !Array.isArray(a)
|
||||
&& !(a instanceof Date)
|
||||
)
|
||||
}
|
||||
|
||||
export function subsetEquality(object: unknown, subset: unknown, customTesters: Array<Tester> = []): boolean | undefined {
|
||||
const filteredCustomTesters = customTesters.filter(t => t !== subsetEquality)
|
||||
export function subsetEquality(
|
||||
object: unknown,
|
||||
subset: unknown,
|
||||
customTesters: Array<Tester> = [],
|
||||
): boolean | undefined {
|
||||
const filteredCustomTesters = customTesters.filter(
|
||||
t => t !== subsetEquality,
|
||||
)
|
||||
// subsetEquality needs to keep track of the references
|
||||
// it has already visited to avoid infinite loops in case
|
||||
// there are circular references in the subset passed to it.
|
||||
const subsetEqualityWithContext
|
||||
= (seenReferences: WeakMap<object, boolean> = new WeakMap()) =>
|
||||
(object: any, subset: any): boolean | undefined => {
|
||||
if (!isObjectWithKeys(subset))
|
||||
if (!isObjectWithKeys(subset)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return Object.keys(subset).every((key) => {
|
||||
if (subset[key] != null && typeof subset[key] === 'object') {
|
||||
if (seenReferences.has(subset[key]))
|
||||
if (seenReferences.has(subset[key])) {
|
||||
return equals(object[key], subset[key], filteredCustomTesters)
|
||||
}
|
||||
|
||||
seenReferences.set(subset[key], true)
|
||||
}
|
||||
const result
|
||||
= object != null
|
||||
&& hasPropertyInObject(object, key)
|
||||
&& equals(object[key], subset[key], [
|
||||
...filteredCustomTesters,
|
||||
subsetEqualityWithContext(seenReferences),
|
||||
])
|
||||
= object != null
|
||||
&& hasPropertyInObject(object, key)
|
||||
&& equals(object[key], subset[key], [
|
||||
...filteredCustomTesters,
|
||||
subsetEqualityWithContext(seenReferences),
|
||||
])
|
||||
// The main goal of using seenReference is to avoid circular node on tree.
|
||||
// It will only happen within a parent and its child, not a node and nodes next to it (same level)
|
||||
// We should keep the reference for a parent and its child only
|
||||
@ -525,19 +585,24 @@ export function subsetEquality(object: unknown, subset: unknown, customTesters:
|
||||
}
|
||||
|
||||
export function typeEquality(a: any, b: any): boolean | undefined {
|
||||
if (a == null || b == null || a.constructor === b.constructor)
|
||||
if (a == null || b == null || a.constructor === b.constructor) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function arrayBufferEquality(a: unknown, b: unknown): boolean | undefined {
|
||||
export function arrayBufferEquality(
|
||||
a: unknown,
|
||||
b: unknown,
|
||||
): boolean | undefined {
|
||||
let dataViewA = a as DataView
|
||||
let dataViewB = b as DataView
|
||||
|
||||
if (!(a instanceof DataView && b instanceof DataView)) {
|
||||
if (!(a instanceof ArrayBuffer) || !(b instanceof ArrayBuffer))
|
||||
if (!(a instanceof ArrayBuffer) || !(b instanceof ArrayBuffer)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
dataViewA = new DataView(a)
|
||||
@ -549,36 +614,48 @@ export function arrayBufferEquality(a: unknown, b: unknown): boolean | undefined
|
||||
}
|
||||
|
||||
// Buffers are not equal when they do not have the same byte length
|
||||
if (dataViewA.byteLength !== dataViewB.byteLength)
|
||||
if (dataViewA.byteLength !== dataViewB.byteLength) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if every byte value is equal to each other
|
||||
for (let i = 0; i < dataViewA.byteLength; i++) {
|
||||
if (dataViewA.getUint8(i) !== dataViewB.getUint8(i))
|
||||
if (dataViewA.getUint8(i) !== dataViewB.getUint8(i)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function sparseArrayEquality(a: unknown, b: unknown, customTesters: Array<Tester> = []): boolean | undefined {
|
||||
if (!Array.isArray(a) || !Array.isArray(b))
|
||||
export function sparseArrayEquality(
|
||||
a: unknown,
|
||||
b: unknown,
|
||||
customTesters: Array<Tester> = [],
|
||||
): boolean | undefined {
|
||||
if (!Array.isArray(a) || !Array.isArray(b)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// A sparse array [, , 1] will have keys ["2"] whereas [undefined, undefined, 1] will have keys ["0", "1", "2"]
|
||||
const aKeys = Object.keys(a)
|
||||
const bKeys = Object.keys(b)
|
||||
const filteredCustomTesters = customTesters.filter(t => t !== sparseArrayEquality)
|
||||
return (
|
||||
equals(a, b, filteredCustomTesters, true) && equals(aKeys, bKeys)
|
||||
const filteredCustomTesters = customTesters.filter(
|
||||
t => t !== sparseArrayEquality,
|
||||
)
|
||||
return equals(a, b, filteredCustomTesters, true) && equals(aKeys, bKeys)
|
||||
}
|
||||
|
||||
export function generateToBeMessage(deepEqualityName: string, expected = '#{this}', actual = '#{exp}') {
|
||||
export function generateToBeMessage(
|
||||
deepEqualityName: string,
|
||||
expected = '#{this}',
|
||||
actual = '#{exp}',
|
||||
) {
|
||||
const toBeMessage = `expected ${expected} to be ${actual} // Object.is equality`
|
||||
|
||||
if (['toStrictEqual', 'toEqual'].includes(deepEqualityName))
|
||||
if (['toStrictEqual', 'toEqual'].includes(deepEqualityName)) {
|
||||
return `${toBeMessage}\n\nIf it should pass with deep equality, replace "toBe" with "${deepEqualityName}"\n\nExpected: ${expected}\nReceived: serializes to the same string\n`
|
||||
}
|
||||
|
||||
return toBeMessage
|
||||
}
|
||||
@ -596,59 +673,73 @@ export function getObjectKeys(object: object): Array<string | symbol> {
|
||||
]
|
||||
}
|
||||
|
||||
export function getObjectSubset(object: any, subset: any, customTesters: Array<Tester> = []): { subset: any; stripped: number } {
|
||||
export function getObjectSubset(
|
||||
object: any,
|
||||
subset: any,
|
||||
customTesters: Array<Tester> = [],
|
||||
): { subset: any; stripped: number } {
|
||||
let stripped = 0
|
||||
|
||||
const getObjectSubsetWithContext = (seenReferences: WeakMap<object, boolean> = new WeakMap()) => (object: any, subset: any): any => {
|
||||
if (Array.isArray(object)) {
|
||||
if (Array.isArray(subset) && subset.length === object.length) {
|
||||
// The map method returns correct subclass of subset.
|
||||
return subset.map((sub: any, i: number) =>
|
||||
getObjectSubsetWithContext(seenReferences)(object[i], sub),
|
||||
)
|
||||
}
|
||||
}
|
||||
else if (object instanceof Date) {
|
||||
return object
|
||||
}
|
||||
else if (isObject(object) && isObject(subset)) {
|
||||
if (
|
||||
equals(object, subset, [
|
||||
...customTesters,
|
||||
iterableEquality,
|
||||
subsetEquality,
|
||||
])
|
||||
) {
|
||||
// Avoid unnecessary copy which might return Object instead of subclass.
|
||||
return subset
|
||||
}
|
||||
|
||||
const trimmed: any = {}
|
||||
seenReferences.set(object, trimmed)
|
||||
|
||||
for (const key of getObjectKeys(object)) {
|
||||
if (hasPropertyInObject(subset, key)) {
|
||||
trimmed[key] = seenReferences.has(object[key])
|
||||
? seenReferences.get(object[key])
|
||||
: getObjectSubsetWithContext(seenReferences)(object[key], subset[key])
|
||||
}
|
||||
else {
|
||||
if (!seenReferences.has(object[key])) {
|
||||
stripped += 1
|
||||
if (isObject(object[key]))
|
||||
stripped += getObjectKeys(object[key]).length
|
||||
|
||||
getObjectSubsetWithContext(seenReferences)(object[key], subset[key])
|
||||
const getObjectSubsetWithContext
|
||||
= (seenReferences: WeakMap<object, boolean> = new WeakMap()) =>
|
||||
(object: any, subset: any): any => {
|
||||
if (Array.isArray(object)) {
|
||||
if (Array.isArray(subset) && subset.length === object.length) {
|
||||
// The map method returns correct subclass of subset.
|
||||
return subset.map((sub: any, i: number) =>
|
||||
getObjectSubsetWithContext(seenReferences)(object[i], sub),
|
||||
)
|
||||
}
|
||||
}
|
||||
else if (object instanceof Date) {
|
||||
return object
|
||||
}
|
||||
else if (isObject(object) && isObject(subset)) {
|
||||
if (
|
||||
equals(object, subset, [
|
||||
...customTesters,
|
||||
iterableEquality,
|
||||
subsetEquality,
|
||||
])
|
||||
) {
|
||||
// Avoid unnecessary copy which might return Object instead of subclass.
|
||||
return subset
|
||||
}
|
||||
|
||||
const trimmed: any = {}
|
||||
seenReferences.set(object, trimmed)
|
||||
|
||||
for (const key of getObjectKeys(object)) {
|
||||
if (hasPropertyInObject(subset, key)) {
|
||||
trimmed[key] = seenReferences.has(object[key])
|
||||
? seenReferences.get(object[key])
|
||||
: getObjectSubsetWithContext(seenReferences)(
|
||||
object[key],
|
||||
subset[key],
|
||||
)
|
||||
}
|
||||
else {
|
||||
if (!seenReferences.has(object[key])) {
|
||||
stripped += 1
|
||||
if (isObject(object[key])) {
|
||||
stripped += getObjectKeys(object[key]).length
|
||||
}
|
||||
|
||||
getObjectSubsetWithContext(seenReferences)(
|
||||
object[key],
|
||||
subset[key],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (getObjectKeys(trimmed).length > 0) {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
return object
|
||||
}
|
||||
|
||||
if (getObjectKeys(trimmed).length > 0)
|
||||
return trimmed
|
||||
}
|
||||
|
||||
return object
|
||||
}
|
||||
|
||||
return { subset: getObjectSubsetWithContext()(object, subset), stripped }
|
||||
}
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import type { ExpectStatic, MatcherState, Tester } from './types'
|
||||
import { ASYMMETRIC_MATCHERS_OBJECT, GLOBAL_EXPECT, JEST_MATCHERS_OBJECT, MATCHERS_OBJECT } from './constants'
|
||||
import {
|
||||
ASYMMETRIC_MATCHERS_OBJECT,
|
||||
GLOBAL_EXPECT,
|
||||
JEST_MATCHERS_OBJECT,
|
||||
MATCHERS_OBJECT,
|
||||
} from './constants'
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(globalThis, MATCHERS_OBJECT)) {
|
||||
const globalState = new WeakMap<ExpectStatic, MatcherState>()
|
||||
@ -22,7 +27,9 @@ if (!Object.prototype.hasOwnProperty.call(globalThis, MATCHERS_OBJECT)) {
|
||||
})
|
||||
}
|
||||
|
||||
export function getState<State extends MatcherState = MatcherState>(expect: ExpectStatic): State {
|
||||
export function getState<State extends MatcherState = MatcherState>(
|
||||
expect: ExpectStatic,
|
||||
): State {
|
||||
return (globalThis as any)[MATCHERS_OBJECT].get(expect)
|
||||
}
|
||||
|
||||
|
||||
@ -16,7 +16,7 @@ export type Tester = (
|
||||
this: TesterContext,
|
||||
a: any,
|
||||
b: any,
|
||||
customTesters: Array<Tester>,
|
||||
customTesters: Array<Tester>
|
||||
) => boolean | undefined
|
||||
|
||||
export interface TesterContext {
|
||||
@ -24,7 +24,7 @@ export interface TesterContext {
|
||||
a: unknown,
|
||||
b: unknown,
|
||||
customTesters?: Array<Tester>,
|
||||
strictCheck?: boolean,
|
||||
strictCheck?: boolean
|
||||
) => boolean
|
||||
}
|
||||
export type { DiffOptions } from '@vitest/utils/diff'
|
||||
@ -50,7 +50,7 @@ export interface MatcherState {
|
||||
a: unknown,
|
||||
b: unknown,
|
||||
customTesters?: Array<Tester>,
|
||||
strictCheck?: boolean,
|
||||
strictCheck?: boolean
|
||||
) => boolean
|
||||
expand?: boolean
|
||||
expectedAssertionsNumber?: number | null
|
||||
@ -88,9 +88,14 @@ export interface RawMatcherFn<T extends MatcherState = MatcherState> {
|
||||
(this: T, received: any, expected: any, options?: any): ExpectationResult
|
||||
}
|
||||
|
||||
export type MatchersObject<T extends MatcherState = MatcherState> = Record<string, RawMatcherFn<T>>
|
||||
export type MatchersObject<T extends MatcherState = MatcherState> = Record<
|
||||
string,
|
||||
RawMatcherFn<T>
|
||||
>
|
||||
|
||||
export interface ExpectStatic extends Chai.ExpectStatic, AsymmetricMatchersContaining {
|
||||
export interface ExpectStatic
|
||||
extends Chai.ExpectStatic,
|
||||
AsymmetricMatchersContaining {
|
||||
<T>(actual: T, message?: string): Assertion<T>
|
||||
extend: (expects: MatchersObject) => void
|
||||
anything: () => any
|
||||
@ -130,7 +135,10 @@ export interface JestAssertion<T = any> extends jest.Matchers<void, T> {
|
||||
toBeInstanceOf: <E>(expected: E) => void
|
||||
toBeCalledTimes: (times: number) => void
|
||||
toHaveLength: (length: number) => void
|
||||
toHaveProperty: <E>(property: string | (string | number)[], value?: E) => void
|
||||
toHaveProperty: <E>(
|
||||
property: string | (string | number)[],
|
||||
value?: E
|
||||
) => void
|
||||
toBeCloseTo: (number: number, numDigits?: number) => void
|
||||
toHaveBeenCalledTimes: (times: number) => void
|
||||
toHaveBeenCalled: () => void
|
||||
@ -160,7 +168,7 @@ type VitestAssertion<A, T> = {
|
||||
? Assertion<T>
|
||||
: A[K] extends (...args: any[]) => any
|
||||
? A[K] // not converting function since they may contain overload
|
||||
: VitestAssertion<A[K], T>
|
||||
: VitestAssertion<A[K], T>;
|
||||
} & ((type: string, message?: string) => Assertion)
|
||||
|
||||
type Promisify<O> = {
|
||||
@ -168,13 +176,25 @@ type Promisify<O> = {
|
||||
? O extends R
|
||||
? Promisify<O[K]>
|
||||
: (...args: A) => Promise<R>
|
||||
: O[K]
|
||||
: O[K];
|
||||
}
|
||||
|
||||
export type PromisifyAssertion<T> = Promisify<Assertion<T>>
|
||||
|
||||
export interface Assertion<T = any> extends VitestAssertion<Chai.Assertion, T>, JestAssertion<T> {
|
||||
toBeTypeOf: (expected: 'bigint' | 'boolean' | 'function' | 'number' | 'object' | 'string' | 'symbol' | 'undefined') => void
|
||||
export interface Assertion<T = any>
|
||||
extends VitestAssertion<Chai.Assertion, T>,
|
||||
JestAssertion<T> {
|
||||
toBeTypeOf: (
|
||||
expected:
|
||||
| 'bigint'
|
||||
| 'boolean'
|
||||
| 'function'
|
||||
| 'number'
|
||||
| 'object'
|
||||
| 'string'
|
||||
| 'symbol'
|
||||
| 'undefined'
|
||||
) => void
|
||||
toHaveBeenCalledOnce: () => void
|
||||
toSatisfy: <E>(matcher: (value: E) => boolean, message?: string) => void
|
||||
|
||||
@ -192,7 +212,6 @@ declare global {
|
||||
// support augmenting jest.Matchers by other libraries
|
||||
// eslint-disable-next-line ts/no-namespace
|
||||
namespace jest {
|
||||
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
interface Matchers<R, T = {}> {}
|
||||
}
|
||||
|
||||
@ -2,34 +2,44 @@ import { processError } from '@vitest/utils/error'
|
||||
import type { Test } from '@vitest/runner/types'
|
||||
import type { Assertion } from './types'
|
||||
|
||||
export function recordAsyncExpect(test: any, promise: Promise<any> | PromiseLike<any>) {
|
||||
export function recordAsyncExpect(
|
||||
test: any,
|
||||
promise: Promise<any> | PromiseLike<any>,
|
||||
) {
|
||||
// record promise for test, that resolves before test ends
|
||||
if (test && promise instanceof Promise) {
|
||||
// if promise is explicitly awaited, remove it from the list
|
||||
promise = promise.finally(() => {
|
||||
const index = test.promises.indexOf(promise)
|
||||
if (index !== -1)
|
||||
if (index !== -1) {
|
||||
test.promises.splice(index, 1)
|
||||
}
|
||||
})
|
||||
|
||||
// record promise
|
||||
if (!test.promises)
|
||||
if (!test.promises) {
|
||||
test.promises = []
|
||||
}
|
||||
test.promises.push(promise)
|
||||
}
|
||||
|
||||
return promise
|
||||
}
|
||||
|
||||
export function wrapSoft(utils: Chai.ChaiUtils, fn: (this: Chai.AssertionStatic & Assertion, ...args: any[]) => void) {
|
||||
export function wrapSoft(
|
||||
utils: Chai.ChaiUtils,
|
||||
fn: (this: Chai.AssertionStatic & Assertion, ...args: any[]) => void,
|
||||
) {
|
||||
return function (this: Chai.AssertionStatic & Assertion, ...args: any[]) {
|
||||
if (!utils.flag(this, 'soft'))
|
||||
if (!utils.flag(this, 'soft')) {
|
||||
return fn.apply(this, args)
|
||||
}
|
||||
|
||||
const test: Test = utils.flag(this, 'vitest-test')
|
||||
|
||||
if (!test)
|
||||
if (!test) {
|
||||
throw new Error('expect.soft() can only be used inside a test')
|
||||
}
|
||||
|
||||
try {
|
||||
return fn.apply(this, args)
|
||||
|
||||
@ -48,15 +48,14 @@ export default defineConfig([
|
||||
format: 'esm',
|
||||
},
|
||||
external,
|
||||
plugins: [
|
||||
dts({ respectExternal: true }),
|
||||
],
|
||||
plugins: [dts({ respectExternal: true })],
|
||||
onwarn,
|
||||
},
|
||||
])
|
||||
|
||||
function onwarn(message) {
|
||||
if (['EMPTY_BUNDLE', 'CIRCULAR_DEPENDENCY'].includes(message.code))
|
||||
if (['EMPTY_BUNDLE', 'CIRCULAR_DEPENDENCY'].includes(message.code)) {
|
||||
return
|
||||
}
|
||||
console.error(message)
|
||||
}
|
||||
|
||||
@ -1,15 +1,27 @@
|
||||
import { processError } from '@vitest/utils/error'
|
||||
import type { File, SuiteHooks } from './types'
|
||||
import type { VitestRunner } from './types/runner'
|
||||
import { calculateSuiteHash, createFileTask, interpretTaskModes, someTasksAreOnly } from './utils/collect'
|
||||
import { clearCollectorContext, createSuiteHooks, getDefaultSuite } from './suite'
|
||||
import {
|
||||
calculateSuiteHash,
|
||||
createFileTask,
|
||||
interpretTaskModes,
|
||||
someTasksAreOnly,
|
||||
} from './utils/collect'
|
||||
import {
|
||||
clearCollectorContext,
|
||||
createSuiteHooks,
|
||||
getDefaultSuite,
|
||||
} from './suite'
|
||||
import { getHooks, setHooks } from './map'
|
||||
import { collectorContext } from './context'
|
||||
import { runSetupFiles } from './setup'
|
||||
|
||||
const now = Date.now
|
||||
|
||||
export async function collectTests(paths: string[], runner: VitestRunner): Promise<File[]> {
|
||||
export async function collectTests(
|
||||
paths: string[],
|
||||
runner: VitestRunner,
|
||||
): Promise<File[]> {
|
||||
const files: File[] = []
|
||||
|
||||
const config = runner.config
|
||||
@ -66,13 +78,20 @@ export async function collectTests(paths: string[], runner: VitestRunner): Promi
|
||||
calculateSuiteHash(file)
|
||||
|
||||
const hasOnlyTasks = someTasksAreOnly(file)
|
||||
interpretTaskModes(file, config.testNamePattern, hasOnlyTasks, false, config.allowOnly)
|
||||
interpretTaskModes(
|
||||
file,
|
||||
config.testNamePattern,
|
||||
hasOnlyTasks,
|
||||
false,
|
||||
config.allowOnly,
|
||||
)
|
||||
|
||||
file.tasks.forEach((task) => {
|
||||
// task.suite refers to the internal default suite object
|
||||
// it should not be reported
|
||||
if (task.suite?.id === '')
|
||||
if (task.suite?.id === '') {
|
||||
delete task.suite
|
||||
}
|
||||
})
|
||||
files.push(file)
|
||||
}
|
||||
@ -83,7 +102,7 @@ export async function collectTests(paths: string[], runner: VitestRunner): Promi
|
||||
function mergeHooks(baseHooks: SuiteHooks, hooks: SuiteHooks): SuiteHooks {
|
||||
for (const _key in hooks) {
|
||||
const key = _key as keyof SuiteHooks
|
||||
baseHooks[key].push(...hooks[key] as any)
|
||||
baseHooks[key].push(...(hooks[key] as any))
|
||||
}
|
||||
|
||||
return baseHooks
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
import type { Awaitable } from '@vitest/utils'
|
||||
import { getSafeTimers } from '@vitest/utils'
|
||||
import type { Custom, ExtendedContext, RuntimeContext, SuiteCollector, TaskContext, Test } from './types'
|
||||
import type {
|
||||
Custom,
|
||||
ExtendedContext,
|
||||
RuntimeContext,
|
||||
SuiteCollector,
|
||||
TaskContext,
|
||||
Test,
|
||||
} from './types'
|
||||
import type { VitestRunner } from './types/runner'
|
||||
import { PendingError } from './errors'
|
||||
|
||||
@ -13,36 +20,46 @@ export function collectTask(task: SuiteCollector) {
|
||||
collectorContext.currentSuite?.tasks.push(task)
|
||||
}
|
||||
|
||||
export async function runWithSuite(suite: SuiteCollector, fn: (() => Awaitable<void>)) {
|
||||
export async function runWithSuite(
|
||||
suite: SuiteCollector,
|
||||
fn: () => Awaitable<void>,
|
||||
) {
|
||||
const prev = collectorContext.currentSuite
|
||||
collectorContext.currentSuite = suite
|
||||
await fn()
|
||||
collectorContext.currentSuite = prev
|
||||
}
|
||||
|
||||
export function withTimeout<T extends((...args: any[]) => any)>(
|
||||
export function withTimeout<T extends (...args: any[]) => any>(
|
||||
fn: T,
|
||||
timeout: number,
|
||||
isHook = false,
|
||||
): T {
|
||||
if (timeout <= 0 || timeout === Number.POSITIVE_INFINITY)
|
||||
if (timeout <= 0 || timeout === Number.POSITIVE_INFINITY) {
|
||||
return fn
|
||||
}
|
||||
|
||||
const { setTimeout, clearTimeout } = getSafeTimers()
|
||||
|
||||
return ((...args: (T extends ((...args: infer A) => any) ? A : never)) => {
|
||||
return Promise.race([fn(...args), new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
clearTimeout(timer)
|
||||
reject(new Error(makeTimeoutMsg(isHook, timeout)))
|
||||
}, timeout)
|
||||
// `unref` might not exist in browser
|
||||
timer.unref?.()
|
||||
})]) as Awaitable<void>
|
||||
return ((...args: T extends (...args: infer A) => any ? A : never) => {
|
||||
return Promise.race([
|
||||
fn(...args),
|
||||
new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
clearTimeout(timer)
|
||||
reject(new Error(makeTimeoutMsg(isHook, timeout)))
|
||||
}, timeout)
|
||||
// `unref` might not exist in browser
|
||||
timer.unref?.()
|
||||
}),
|
||||
]) as Awaitable<void>
|
||||
}) as T
|
||||
}
|
||||
|
||||
export function createTestContext<T extends Test | Custom>(test: T, runner: VitestRunner): ExtendedContext<T> {
|
||||
export function createTestContext<T extends Test | Custom>(
|
||||
test: T,
|
||||
runner: VitestRunner,
|
||||
): ExtendedContext<T> {
|
||||
const context = function () {
|
||||
throw new Error('done() callback is deprecated, use promise instead')
|
||||
} as unknown as TaskContext<T>
|
||||
@ -64,9 +81,15 @@ export function createTestContext<T extends Test | Custom>(test: T, runner: Vite
|
||||
test.onFinished.push(fn)
|
||||
}
|
||||
|
||||
return runner.extendTaskContext?.(context) as ExtendedContext<T> || context
|
||||
return (runner.extendTaskContext?.(context) as ExtendedContext<T>) || context
|
||||
}
|
||||
|
||||
function makeTimeoutMsg(isHook: boolean, timeout: number) {
|
||||
return `${isHook ? 'Hook' : 'Test'} timed out in ${timeout}ms.\nIf this is a long-running ${isHook ? 'hook' : 'test'}, pass a timeout value as the last argument or configure it globally with "${isHook ? 'hookTimeout' : 'testTimeout'}".`
|
||||
return `${
|
||||
isHook ? 'Hook' : 'Test'
|
||||
} timed out in ${timeout}ms.\nIf this is a long-running ${
|
||||
isHook ? 'hook' : 'test'
|
||||
}, pass a timeout value as the last argument or configure it globally with "${
|
||||
isHook ? 'hookTimeout' : 'testTimeout'
|
||||
}".`
|
||||
}
|
||||
|
||||
@ -15,14 +15,18 @@ export interface FixtureItem extends FixtureOptions {
|
||||
deps?: FixtureItem[]
|
||||
}
|
||||
|
||||
export function mergeContextFixtures(fixtures: Record<string, any>, context: { fixtures?: FixtureItem[] } = {}) {
|
||||
export function mergeContextFixtures(
|
||||
fixtures: Record<string, any>,
|
||||
context: { fixtures?: FixtureItem[] } = {},
|
||||
) {
|
||||
const fixtureOptionKeys = ['auto']
|
||||
const fixtureArray: FixtureItem[] = Object.entries(fixtures)
|
||||
.map(([prop, value]) => {
|
||||
const fixtureArray: FixtureItem[] = Object.entries(fixtures).map(
|
||||
([prop, value]) => {
|
||||
const fixtureItem = { value } as FixtureItem
|
||||
|
||||
if (
|
||||
Array.isArray(value) && value.length >= 2
|
||||
Array.isArray(value)
|
||||
&& value.length >= 2
|
||||
&& isObject(value[1])
|
||||
&& Object.keys(value[1]).some(key => fixtureOptionKeys.includes(key))
|
||||
) {
|
||||
@ -34,19 +38,25 @@ export function mergeContextFixtures(fixtures: Record<string, any>, context: { f
|
||||
fixtureItem.prop = prop
|
||||
fixtureItem.isFn = typeof fixtureItem.value === 'function'
|
||||
return fixtureItem
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
if (Array.isArray(context.fixtures))
|
||||
if (Array.isArray(context.fixtures)) {
|
||||
context.fixtures = context.fixtures.concat(fixtureArray)
|
||||
else
|
||||
}
|
||||
else {
|
||||
context.fixtures = fixtureArray
|
||||
}
|
||||
|
||||
// Update dependencies of fixture functions
|
||||
fixtureArray.forEach((fixture) => {
|
||||
if (fixture.isFn) {
|
||||
const usedProps = getUsedProps(fixture.value)
|
||||
if (usedProps.length)
|
||||
fixture.deps = context.fixtures!.filter(({ prop }) => prop !== fixture.prop && usedProps.includes(prop))
|
||||
if (usedProps.length) {
|
||||
fixture.deps = context.fixtures!.filter(
|
||||
({ prop }) => prop !== fixture.prop && usedProps.includes(prop),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -54,52 +64,69 @@ export function mergeContextFixtures(fixtures: Record<string, any>, context: { f
|
||||
}
|
||||
|
||||
const fixtureValueMaps = new Map<TestContext, Map<FixtureItem, any>>()
|
||||
const cleanupFnArrayMap = new Map<TestContext, Array<() => void | Promise<void>>>()
|
||||
const cleanupFnArrayMap = new Map<
|
||||
TestContext,
|
||||
Array<() => void | Promise<void>>
|
||||
>()
|
||||
|
||||
export async function callFixtureCleanup(context: TestContext) {
|
||||
const cleanupFnArray = cleanupFnArrayMap.get(context) ?? []
|
||||
for (const cleanup of cleanupFnArray.reverse())
|
||||
for (const cleanup of cleanupFnArray.reverse()) {
|
||||
await cleanup()
|
||||
}
|
||||
cleanupFnArrayMap.delete(context)
|
||||
}
|
||||
|
||||
export function withFixtures(fn: Function, testContext?: TestContext) {
|
||||
return (hookContext?: TestContext) => {
|
||||
const context: TestContext & { [key: string]: any } | undefined = hookContext || testContext
|
||||
const context: (TestContext & { [key: string]: any }) | undefined
|
||||
= hookContext || testContext
|
||||
|
||||
if (!context)
|
||||
if (!context) {
|
||||
return fn({})
|
||||
}
|
||||
|
||||
const fixtures = getFixture(context)
|
||||
if (!fixtures?.length)
|
||||
if (!fixtures?.length) {
|
||||
return fn(context)
|
||||
}
|
||||
|
||||
const usedProps = getUsedProps(fn)
|
||||
const hasAutoFixture = fixtures.some(({ auto }) => auto)
|
||||
if (!usedProps.length && !hasAutoFixture)
|
||||
if (!usedProps.length && !hasAutoFixture) {
|
||||
return fn(context)
|
||||
}
|
||||
|
||||
if (!fixtureValueMaps.get(context))
|
||||
if (!fixtureValueMaps.get(context)) {
|
||||
fixtureValueMaps.set(context, new Map<FixtureItem, any>())
|
||||
const fixtureValueMap: Map<FixtureItem, any> = fixtureValueMaps.get(context)!
|
||||
}
|
||||
const fixtureValueMap: Map<FixtureItem, any>
|
||||
= fixtureValueMaps.get(context)!
|
||||
|
||||
if (!cleanupFnArrayMap.has(context))
|
||||
if (!cleanupFnArrayMap.has(context)) {
|
||||
cleanupFnArrayMap.set(context, [])
|
||||
}
|
||||
const cleanupFnArray = cleanupFnArrayMap.get(context)!
|
||||
|
||||
const usedFixtures = fixtures.filter(({ prop, auto }) => auto || usedProps.includes(prop))
|
||||
const usedFixtures = fixtures.filter(
|
||||
({ prop, auto }) => auto || usedProps.includes(prop),
|
||||
)
|
||||
const pendingFixtures = resolveDeps(usedFixtures)
|
||||
|
||||
if (!pendingFixtures.length)
|
||||
if (!pendingFixtures.length) {
|
||||
return fn(context)
|
||||
}
|
||||
|
||||
async function resolveFixtures() {
|
||||
for (const fixture of pendingFixtures) {
|
||||
// fixture could be already initialized during "before" hook
|
||||
if (fixtureValueMap.has(fixture))
|
||||
if (fixtureValueMap.has(fixture)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const resolvedValue = fixture.isFn ? await resolveFixtureFunction(fixture.value, context, cleanupFnArray) : fixture.value
|
||||
const resolvedValue = fixture.isFn
|
||||
? await resolveFixtureFunction(fixture.value, context, cleanupFnArray)
|
||||
: fixture.value
|
||||
context![fixture.prop] = resolvedValue
|
||||
fixtureValueMap.set(fixture, resolvedValue)
|
||||
cleanupFnArray.unshift(() => {
|
||||
@ -113,9 +140,12 @@ export function withFixtures(fn: Function, testContext?: TestContext) {
|
||||
}
|
||||
|
||||
async function resolveFixtureFunction(
|
||||
fixtureFn: (context: unknown, useFn: (arg: unknown) => Promise<void>) => Promise<void>,
|
||||
fixtureFn: (
|
||||
context: unknown,
|
||||
useFn: (arg: unknown) => Promise<void>
|
||||
) => Promise<void>,
|
||||
context: unknown,
|
||||
cleanupFnArray: (() => (void | Promise<void>))[],
|
||||
cleanupFnArray: (() => void | Promise<void>)[],
|
||||
): Promise<unknown> {
|
||||
// wait for `use` call to extract fixture value
|
||||
const useFnArgPromise = createDefer()
|
||||
@ -148,16 +178,27 @@ async function resolveFixtureFunction(
|
||||
return useFnArgPromise
|
||||
}
|
||||
|
||||
function resolveDeps(fixtures: FixtureItem[], depSet = new Set<FixtureItem>(), pendingFixtures: FixtureItem[] = []) {
|
||||
function resolveDeps(
|
||||
fixtures: FixtureItem[],
|
||||
depSet = new Set<FixtureItem>(),
|
||||
pendingFixtures: FixtureItem[] = [],
|
||||
) {
|
||||
fixtures.forEach((fixture) => {
|
||||
if (pendingFixtures.includes(fixture))
|
||||
if (pendingFixtures.includes(fixture)) {
|
||||
return
|
||||
}
|
||||
if (!fixture.isFn || !fixture.deps) {
|
||||
pendingFixtures.push(fixture)
|
||||
return
|
||||
}
|
||||
if (depSet.has(fixture))
|
||||
throw new Error(`Circular fixture dependency detected: ${fixture.prop} <- ${[...depSet].reverse().map(d => d.prop).join(' <- ')}`)
|
||||
if (depSet.has(fixture)) {
|
||||
throw new Error(
|
||||
`Circular fixture dependency detected: ${fixture.prop} <- ${[...depSet]
|
||||
.reverse()
|
||||
.map(d => d.prop)
|
||||
.join(' <- ')}`,
|
||||
)
|
||||
}
|
||||
|
||||
depSet.add(fixture)
|
||||
resolveDeps(fixture.deps, depSet, pendingFixtures)
|
||||
@ -170,22 +211,28 @@ function resolveDeps(fixtures: FixtureItem[], depSet = new Set<FixtureItem>(), p
|
||||
|
||||
function getUsedProps(fn: Function) {
|
||||
const match = fn.toString().match(/[^(]*\(([^)]*)/)
|
||||
if (!match)
|
||||
if (!match) {
|
||||
return []
|
||||
}
|
||||
|
||||
const args = splitByComma(match[1])
|
||||
if (!args.length)
|
||||
if (!args.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
let first = args[0]
|
||||
if ('__VITEST_FIXTURE_INDEX__' in fn) {
|
||||
first = args[(fn as any).__VITEST_FIXTURE_INDEX__]
|
||||
if (!first)
|
||||
if (!first) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
if (!(first.startsWith('{') && first.endsWith('}')))
|
||||
throw new Error(`The first argument inside a fixture must use object destructuring pattern, e.g. ({ test } => {}). Instead, received "${first}".`)
|
||||
if (!(first.startsWith('{') && first.endsWith('}'))) {
|
||||
throw new Error(
|
||||
`The first argument inside a fixture must use object destructuring pattern, e.g. ({ test } => {}). Instead, received "${first}".`,
|
||||
)
|
||||
}
|
||||
|
||||
const _first = first.slice(1, -1).replace(/\s/g, '')
|
||||
const props = splitByComma(_first).map((prop) => {
|
||||
@ -193,8 +240,11 @@ function getUsedProps(fn: Function) {
|
||||
})
|
||||
|
||||
const last = props.at(-1)
|
||||
if (last && last.startsWith('...'))
|
||||
throw new Error(`Rest parameters are not supported in fixtures, received "${last}".`)
|
||||
if (last && last.startsWith('...')) {
|
||||
throw new Error(
|
||||
`Rest parameters are not supported in fixtures, received "${last}".`,
|
||||
)
|
||||
}
|
||||
|
||||
return props
|
||||
}
|
||||
@ -212,13 +262,15 @@ function splitByComma(s: string) {
|
||||
}
|
||||
else if (!stack.length && s[i] === ',') {
|
||||
const token = s.substring(start, i).trim()
|
||||
if (token)
|
||||
if (token) {
|
||||
result.push(token)
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
const lastToken = s.substring(start).trim()
|
||||
if (lastToken)
|
||||
if (lastToken) {
|
||||
result.push(lastToken)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import type { OnTestFailedHandler, OnTestFinishedHandler, SuiteHooks, TaskPopulated } from './types'
|
||||
import type {
|
||||
OnTestFailedHandler,
|
||||
OnTestFinishedHandler,
|
||||
SuiteHooks,
|
||||
TaskPopulated,
|
||||
} from './types'
|
||||
import { getCurrentSuite, getRunner } from './suite'
|
||||
import { getCurrentTest } from './test-state'
|
||||
import { withTimeout } from './context'
|
||||
@ -10,34 +15,62 @@ function getDefaultHookTimeout() {
|
||||
|
||||
// suite hooks
|
||||
export function beforeAll(fn: SuiteHooks['beforeAll'][0], timeout?: number) {
|
||||
return getCurrentSuite().on('beforeAll', withTimeout(fn, timeout ?? getDefaultHookTimeout(), true))
|
||||
return getCurrentSuite().on(
|
||||
'beforeAll',
|
||||
withTimeout(fn, timeout ?? getDefaultHookTimeout(), true),
|
||||
)
|
||||
}
|
||||
export function afterAll(fn: SuiteHooks['afterAll'][0], timeout?: number) {
|
||||
return getCurrentSuite().on('afterAll', withTimeout(fn, timeout ?? getDefaultHookTimeout(), true))
|
||||
return getCurrentSuite().on(
|
||||
'afterAll',
|
||||
withTimeout(fn, timeout ?? getDefaultHookTimeout(), true),
|
||||
)
|
||||
}
|
||||
export function beforeEach<ExtraContext = {}>(fn: SuiteHooks<ExtraContext>['beforeEach'][0], timeout?: number) {
|
||||
return getCurrentSuite<ExtraContext>().on('beforeEach', withTimeout(withFixtures(fn), timeout ?? getDefaultHookTimeout(), true))
|
||||
export function beforeEach<ExtraContext = {}>(
|
||||
fn: SuiteHooks<ExtraContext>['beforeEach'][0],
|
||||
timeout?: number,
|
||||
) {
|
||||
return getCurrentSuite<ExtraContext>().on(
|
||||
'beforeEach',
|
||||
withTimeout(withFixtures(fn), timeout ?? getDefaultHookTimeout(), true),
|
||||
)
|
||||
}
|
||||
export function afterEach<ExtraContext = {}>(fn: SuiteHooks<ExtraContext>['afterEach'][0], timeout?: number) {
|
||||
return getCurrentSuite<ExtraContext>().on('afterEach', withTimeout(withFixtures(fn), timeout ?? getDefaultHookTimeout(), true))
|
||||
export function afterEach<ExtraContext = {}>(
|
||||
fn: SuiteHooks<ExtraContext>['afterEach'][0],
|
||||
timeout?: number,
|
||||
) {
|
||||
return getCurrentSuite<ExtraContext>().on(
|
||||
'afterEach',
|
||||
withTimeout(withFixtures(fn), timeout ?? getDefaultHookTimeout(), true),
|
||||
)
|
||||
}
|
||||
|
||||
export const onTestFailed = createTestHook<OnTestFailedHandler>('onTestFailed', (test, handler) => {
|
||||
test.onFailed ||= []
|
||||
test.onFailed.push(handler)
|
||||
})
|
||||
export const onTestFailed = createTestHook<OnTestFailedHandler>(
|
||||
'onTestFailed',
|
||||
(test, handler) => {
|
||||
test.onFailed ||= []
|
||||
test.onFailed.push(handler)
|
||||
},
|
||||
)
|
||||
|
||||
export const onTestFinished = createTestHook<OnTestFinishedHandler>('onTestFinished', (test, handler) => {
|
||||
test.onFinished ||= []
|
||||
test.onFinished.push(handler)
|
||||
})
|
||||
export const onTestFinished = createTestHook<OnTestFinishedHandler>(
|
||||
'onTestFinished',
|
||||
(test, handler) => {
|
||||
test.onFinished ||= []
|
||||
test.onFinished.push(handler)
|
||||
},
|
||||
)
|
||||
|
||||
function createTestHook<T>(name: string, handler: (test: TaskPopulated, handler: T) => void) {
|
||||
function createTestHook<T>(
|
||||
name: string,
|
||||
handler: (test: TaskPopulated, handler: T) => void,
|
||||
) {
|
||||
return (fn: T) => {
|
||||
const current = getCurrentTest()
|
||||
|
||||
if (!current)
|
||||
if (!current) {
|
||||
throw new Error(`Hook ${name}() can only be called inside a test`)
|
||||
}
|
||||
|
||||
return handler(current, fn)
|
||||
}
|
||||
|
||||
@ -1,6 +1,20 @@
|
||||
export { startTests, updateTask } from './run'
|
||||
export { test, it, describe, suite, getCurrentSuite, createTaskCollector } from './suite'
|
||||
export { beforeAll, beforeEach, afterAll, afterEach, onTestFailed, onTestFinished } from './hooks'
|
||||
export {
|
||||
test,
|
||||
it,
|
||||
describe,
|
||||
suite,
|
||||
getCurrentSuite,
|
||||
createTaskCollector,
|
||||
} from './suite'
|
||||
export {
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
afterAll,
|
||||
afterEach,
|
||||
onTestFailed,
|
||||
onTestFinished,
|
||||
} from './hooks'
|
||||
export { setFn, getFn, getHooks, setHooks } from './map'
|
||||
export { getCurrentTest } from './test-state'
|
||||
export { processError } from '@vitest/utils/error'
|
||||
|
||||
@ -7,15 +7,18 @@ const fnMap = new WeakMap()
|
||||
const fixtureMap = new WeakMap()
|
||||
const hooksMap = new WeakMap()
|
||||
|
||||
export function setFn(key: Test | Custom, fn: (() => Awaitable<void>)) {
|
||||
export function setFn(key: Test | Custom, fn: () => Awaitable<void>) {
|
||||
fnMap.set(key, fn)
|
||||
}
|
||||
|
||||
export function getFn<Task = Test | Custom>(key: Task): (() => Awaitable<void>) {
|
||||
export function getFn<Task = Test | Custom>(key: Task): () => Awaitable<void> {
|
||||
return fnMap.get(key as any)
|
||||
}
|
||||
|
||||
export function setFixture(key: TestContext, fixture: FixtureItem[] | undefined) {
|
||||
export function setFixture(
|
||||
key: TestContext,
|
||||
fixture: FixtureItem[] | undefined,
|
||||
) {
|
||||
fixtureMap.set(key, fixture)
|
||||
}
|
||||
|
||||
|
||||
@ -4,7 +4,21 @@ import { getSafeTimers, shuffle } from '@vitest/utils'
|
||||
import { processError } from '@vitest/utils/error'
|
||||
import type { DiffOptions } from '@vitest/utils/diff'
|
||||
import type { VitestRunner } from './types/runner'
|
||||
import type { Custom, File, HookCleanupCallback, HookListener, SequenceHooks, Suite, SuiteHooks, Task, TaskMeta, TaskResult, TaskResultPack, TaskState, Test } from './types'
|
||||
import type {
|
||||
Custom,
|
||||
File,
|
||||
HookCleanupCallback,
|
||||
HookListener,
|
||||
SequenceHooks,
|
||||
Suite,
|
||||
SuiteHooks,
|
||||
Task,
|
||||
TaskMeta,
|
||||
TaskResult,
|
||||
TaskResultPack,
|
||||
TaskState,
|
||||
Test,
|
||||
} from './types'
|
||||
import { partitionSuiteChildren } from './utils/suite'
|
||||
import { getFn, getHooks } from './map'
|
||||
import { collectTests } from './collect'
|
||||
@ -15,11 +29,18 @@ import { callFixtureCleanup } from './fixture'
|
||||
|
||||
const now = Date.now
|
||||
|
||||
function updateSuiteHookState(suite: Task, name: keyof SuiteHooks, state: TaskState, runner: VitestRunner) {
|
||||
if (!suite.result)
|
||||
function updateSuiteHookState(
|
||||
suite: Task,
|
||||
name: keyof SuiteHooks,
|
||||
state: TaskState,
|
||||
runner: VitestRunner,
|
||||
) {
|
||||
if (!suite.result) {
|
||||
suite.result = { state: 'run' }
|
||||
if (!suite.result?.hooks)
|
||||
}
|
||||
if (!suite.result?.hooks) {
|
||||
suite.result.hooks = {}
|
||||
}
|
||||
const suiteHooks = suite.result.hooks
|
||||
if (suiteHooks) {
|
||||
suiteHooks[name] = state
|
||||
@ -27,23 +48,34 @@ function updateSuiteHookState(suite: Task, name: keyof SuiteHooks, state: TaskSt
|
||||
}
|
||||
}
|
||||
|
||||
function getSuiteHooks(suite: Suite, name: keyof SuiteHooks, sequence: SequenceHooks) {
|
||||
function getSuiteHooks(
|
||||
suite: Suite,
|
||||
name: keyof SuiteHooks,
|
||||
sequence: SequenceHooks,
|
||||
) {
|
||||
const hooks = getHooks(suite)[name]
|
||||
if (sequence === 'stack' && (name === 'afterAll' || name === 'afterEach'))
|
||||
if (sequence === 'stack' && (name === 'afterAll' || name === 'afterEach')) {
|
||||
return hooks.slice().reverse()
|
||||
}
|
||||
return hooks
|
||||
}
|
||||
|
||||
async function callTaskHooks(task: Task, hooks: ((result: TaskResult) => Awaitable<void>)[], sequence: SequenceHooks) {
|
||||
if (sequence === 'stack')
|
||||
async function callTaskHooks(
|
||||
task: Task,
|
||||
hooks: ((result: TaskResult) => Awaitable<void>)[],
|
||||
sequence: SequenceHooks,
|
||||
) {
|
||||
if (sequence === 'stack') {
|
||||
hooks = hooks.slice().reverse()
|
||||
}
|
||||
|
||||
if (sequence === 'parallel') {
|
||||
await Promise.all(hooks.map(fn => fn(task.result!)))
|
||||
}
|
||||
else {
|
||||
for (const fn of hooks)
|
||||
for (const fn of hooks) {
|
||||
await fn(task.result!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -58,13 +90,12 @@ export async function callSuiteHook<T extends keyof SuiteHooks>(
|
||||
|
||||
const callbacks: HookCleanupCallback[] = []
|
||||
// stop at file level
|
||||
const parentSuite: Suite | null = 'filepath' in suite
|
||||
? null
|
||||
: (suite.suite || suite.file)
|
||||
const parentSuite: Suite | null
|
||||
= 'filepath' in suite ? null : suite.suite || suite.file
|
||||
|
||||
if (name === 'beforeEach' && parentSuite) {
|
||||
callbacks.push(
|
||||
...await callSuiteHook(parentSuite, currentTask, name, runner, args),
|
||||
...(await callSuiteHook(parentSuite, currentTask, name, runner, args)),
|
||||
)
|
||||
}
|
||||
|
||||
@ -73,18 +104,21 @@ export async function callSuiteHook<T extends keyof SuiteHooks>(
|
||||
const hooks = getSuiteHooks(suite, name, sequence)
|
||||
|
||||
if (sequence === 'parallel') {
|
||||
callbacks.push(...await Promise.all(hooks.map(fn => fn(...args as any))))
|
||||
callbacks.push(
|
||||
...(await Promise.all(hooks.map(fn => fn(...(args as any))))),
|
||||
)
|
||||
}
|
||||
else {
|
||||
for (const hook of hooks)
|
||||
callbacks.push(await hook(...args as any))
|
||||
for (const hook of hooks) {
|
||||
callbacks.push(await hook(...(args as any)))
|
||||
}
|
||||
}
|
||||
|
||||
updateSuiteHookState(currentTask, name, 'pass', runner)
|
||||
|
||||
if (name === 'afterEach' && parentSuite) {
|
||||
callbacks.push(
|
||||
...await callSuiteHook(parentSuite, currentTask, name, runner, args),
|
||||
...(await callSuiteHook(parentSuite, currentTask, name, runner, args)),
|
||||
)
|
||||
}
|
||||
|
||||
@ -113,11 +147,7 @@ async function sendTasksUpdate(runner: VitestRunner) {
|
||||
|
||||
if (packs.size) {
|
||||
const taskPacks = Array.from(packs).map<TaskResultPack>(([id, task]) => {
|
||||
return [
|
||||
id,
|
||||
task[0],
|
||||
task[1],
|
||||
]
|
||||
return [id, task[0], task[1]]
|
||||
})
|
||||
const p = runner.onTaskUpdate?.(taskPacks)
|
||||
packs.clear()
|
||||
@ -126,18 +156,22 @@ async function sendTasksUpdate(runner: VitestRunner) {
|
||||
}
|
||||
|
||||
async function callCleanupHooks(cleanups: HookCleanupCallback[]) {
|
||||
await Promise.all(cleanups.map(async (fn) => {
|
||||
if (typeof fn !== 'function')
|
||||
return
|
||||
await fn()
|
||||
}))
|
||||
await Promise.all(
|
||||
cleanups.map(async (fn) => {
|
||||
if (typeof fn !== 'function') {
|
||||
return
|
||||
}
|
||||
await fn()
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export async function runTest(test: Test | Custom, runner: VitestRunner) {
|
||||
await runner.onBeforeRunTask?.(test)
|
||||
|
||||
if (test.mode !== 'run')
|
||||
if (test.mode !== 'run') {
|
||||
return
|
||||
}
|
||||
|
||||
if (test.result?.state === 'fail') {
|
||||
updateTask(test, runner)
|
||||
@ -163,36 +197,56 @@ export async function runTest(test: Test | Custom, runner: VitestRunner) {
|
||||
for (let retryCount = 0; retryCount <= retry; retryCount++) {
|
||||
let beforeEachCleanups: HookCleanupCallback[] = []
|
||||
try {
|
||||
await runner.onBeforeTryTask?.(test, { retry: retryCount, repeats: repeatCount })
|
||||
await runner.onBeforeTryTask?.(test, {
|
||||
retry: retryCount,
|
||||
repeats: repeatCount,
|
||||
})
|
||||
|
||||
test.result.repeatCount = repeatCount
|
||||
|
||||
beforeEachCleanups = await callSuiteHook(suite, test, 'beforeEach', runner, [test.context, suite])
|
||||
beforeEachCleanups = await callSuiteHook(
|
||||
suite,
|
||||
test,
|
||||
'beforeEach',
|
||||
runner,
|
||||
[test.context, suite],
|
||||
)
|
||||
|
||||
if (runner.runTask) {
|
||||
await runner.runTask(test)
|
||||
}
|
||||
else {
|
||||
const fn = getFn(test)
|
||||
if (!fn)
|
||||
throw new Error('Test function is not found. Did you add it using `setFn`?')
|
||||
if (!fn) {
|
||||
throw new Error(
|
||||
'Test function is not found. Did you add it using `setFn`?',
|
||||
)
|
||||
}
|
||||
await fn()
|
||||
}
|
||||
// some async expect will be added to this array, in case user forget to await theme
|
||||
if (test.promises) {
|
||||
const result = await Promise.allSettled(test.promises)
|
||||
const errors = result.map(r => r.status === 'rejected' ? r.reason : undefined).filter(Boolean)
|
||||
if (errors.length)
|
||||
const errors = result
|
||||
.map(r => (r.status === 'rejected' ? r.reason : undefined))
|
||||
.filter(Boolean)
|
||||
if (errors.length) {
|
||||
throw errors
|
||||
}
|
||||
}
|
||||
|
||||
await runner.onAfterTryTask?.(test, { retry: retryCount, repeats: repeatCount })
|
||||
await runner.onAfterTryTask?.(test, {
|
||||
retry: retryCount,
|
||||
repeats: repeatCount,
|
||||
})
|
||||
|
||||
if (test.result.state !== 'fail') {
|
||||
if (!test.repeats)
|
||||
if (!test.repeats) {
|
||||
test.result.state = 'pass'
|
||||
else if (test.repeats && retry === retryCount)
|
||||
}
|
||||
else if (test.repeats && retry === retryCount) {
|
||||
test.result.state = 'pass'
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
@ -209,7 +263,10 @@ export async function runTest(test: Test | Custom, runner: VitestRunner) {
|
||||
}
|
||||
|
||||
try {
|
||||
await callSuiteHook(suite, test, 'afterEach', runner, [test.context, suite])
|
||||
await callSuiteHook(suite, test, 'afterEach', runner, [
|
||||
test.context,
|
||||
suite,
|
||||
])
|
||||
await callCleanupHooks(beforeEachCleanups)
|
||||
await callFixtureCleanup(test.context)
|
||||
}
|
||||
@ -217,8 +274,9 @@ export async function runTest(test: Test | Custom, runner: VitestRunner) {
|
||||
failTask(test.result, e, runner.config.diffOptions)
|
||||
}
|
||||
|
||||
if (test.result.state === 'pass')
|
||||
if (test.result.state === 'pass') {
|
||||
break
|
||||
}
|
||||
|
||||
if (retryCount < retry) {
|
||||
// reset state when retry test
|
||||
@ -240,7 +298,11 @@ export async function runTest(test: Test | Custom, runner: VitestRunner) {
|
||||
|
||||
if (test.result.state === 'fail') {
|
||||
try {
|
||||
await callTaskHooks(test, test.onFailed || [], runner.config.sequence.hooks)
|
||||
await callTaskHooks(
|
||||
test,
|
||||
test.onFailed || [],
|
||||
runner.config.sequence.hooks,
|
||||
)
|
||||
}
|
||||
catch (e) {
|
||||
failTask(test.result, e, runner.config.diffOptions)
|
||||
@ -276,9 +338,7 @@ function failTask(result: TaskResult, err: unknown, diffOptions?: DiffOptions) {
|
||||
}
|
||||
|
||||
result.state = 'fail'
|
||||
const errors = Array.isArray(err)
|
||||
? err
|
||||
: [err]
|
||||
const errors = Array.isArray(err) ? err : [err]
|
||||
for (const e of errors) {
|
||||
const error = processError(e, diffOptions)
|
||||
result.errors ??= []
|
||||
@ -291,8 +351,9 @@ function markTasksAsSkipped(suite: Suite, runner: VitestRunner) {
|
||||
t.mode = 'skip'
|
||||
t.result = { ...t.result, state: 'skip' }
|
||||
updateTask(t, runner)
|
||||
if (t.type === 'suite')
|
||||
if (t.type === 'suite') {
|
||||
markTasksAsSkipped(t, runner)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -324,7 +385,13 @@ export async function runSuite(suite: Suite, runner: VitestRunner) {
|
||||
}
|
||||
else {
|
||||
try {
|
||||
beforeAllCleanups = await callSuiteHook(suite, suite, 'beforeAll', runner, [suite])
|
||||
beforeAllCleanups = await callSuiteHook(
|
||||
suite,
|
||||
suite,
|
||||
'beforeAll',
|
||||
runner,
|
||||
[suite],
|
||||
)
|
||||
|
||||
if (runner.runSuite) {
|
||||
await runner.runSuite(suite)
|
||||
@ -338,13 +405,18 @@ export async function runSuite(suite: Suite, runner: VitestRunner) {
|
||||
const { sequence } = runner.config
|
||||
if (sequence.shuffle || suite.shuffle) {
|
||||
// run describe block independently from tests
|
||||
const suites = tasksGroup.filter(group => group.type === 'suite')
|
||||
const suites = tasksGroup.filter(
|
||||
group => group.type === 'suite',
|
||||
)
|
||||
const tests = tasksGroup.filter(group => group.type === 'test')
|
||||
const groups = shuffle<Task[]>([suites, tests], sequence.seed)
|
||||
tasksGroup = groups.flatMap(group => shuffle(group, sequence.seed))
|
||||
tasksGroup = groups.flatMap(group =>
|
||||
shuffle(group, sequence.seed),
|
||||
)
|
||||
}
|
||||
for (const c of tasksGroup)
|
||||
for (const c of tasksGroup) {
|
||||
await runSuiteChild(c, runner)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -365,7 +437,9 @@ export async function runSuite(suite: Suite, runner: VitestRunner) {
|
||||
if (!runner.config.passWithNoTests && !hasTests(suite)) {
|
||||
suite.result.state = 'fail'
|
||||
if (!suite.result.errors?.length) {
|
||||
const error = processError(new Error(`No test found in suite ${suite.name}`))
|
||||
const error = processError(
|
||||
new Error(`No test found in suite ${suite.name}`),
|
||||
)
|
||||
suite.result.errors = [error]
|
||||
}
|
||||
}
|
||||
@ -388,11 +462,12 @@ export async function runSuite(suite: Suite, runner: VitestRunner) {
|
||||
let limitMaxConcurrency: ReturnType<typeof limit>
|
||||
|
||||
async function runSuiteChild(c: Task, runner: VitestRunner) {
|
||||
if (c.type === 'test' || c.type === 'custom')
|
||||
if (c.type === 'test' || c.type === 'custom') {
|
||||
return limitMaxConcurrency(() => runTest(c, runner))
|
||||
|
||||
else if (c.type === 'suite')
|
||||
}
|
||||
else if (c.type === 'suite') {
|
||||
return runSuite(c, runner)
|
||||
}
|
||||
}
|
||||
|
||||
export async function runFiles(files: File[], runner: VitestRunner) {
|
||||
@ -401,7 +476,9 @@ export async function runFiles(files: File[], runner: VitestRunner) {
|
||||
for (const file of files) {
|
||||
if (!file.tasks.length && !runner.config.passWithNoTests) {
|
||||
if (!file.result?.errors?.length) {
|
||||
const error = processError(new Error(`No test suite found in file ${file.filepath}`))
|
||||
const error = processError(
|
||||
new Error(`No test suite found in file ${file.filepath}`),
|
||||
)
|
||||
file.result = {
|
||||
state: 'fail',
|
||||
errors: [error],
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import { toArray } from '@vitest/utils'
|
||||
import type { VitestRunner, VitestRunnerConfig } from './types'
|
||||
|
||||
export async function runSetupFiles(config: VitestRunnerConfig, runner: VitestRunner) {
|
||||
export async function runSetupFiles(
|
||||
config: VitestRunnerConfig,
|
||||
runner: VitestRunner,
|
||||
) {
|
||||
const files = toArray(config.setupFiles)
|
||||
if (config.sequence.setupFiles === 'parallel') {
|
||||
await Promise.all(
|
||||
@ -11,7 +14,8 @@ export async function runSetupFiles(config: VitestRunnerConfig, runner: VitestRu
|
||||
)
|
||||
}
|
||||
else {
|
||||
for (const fsPath of files)
|
||||
for (const fsPath of files) {
|
||||
await runner.importFile(fsPath, 'setup')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,39 @@
|
||||
import { format, isNegativeNaN, isObject, objDisplay, objectAttr, toArray } from '@vitest/utils'
|
||||
import {
|
||||
format,
|
||||
isNegativeNaN,
|
||||
isObject,
|
||||
objDisplay,
|
||||
objectAttr,
|
||||
toArray,
|
||||
} from '@vitest/utils'
|
||||
import { parseSingleStack } from '@vitest/utils/source-map'
|
||||
import type { Custom, CustomAPI, File, Fixtures, RunMode, Suite, SuiteAPI, SuiteCollector, SuiteFactory, SuiteHooks, Task, TaskCustomOptions, Test, TestAPI, TestFunction, TestOptions } from './types'
|
||||
import type {
|
||||
Custom,
|
||||
CustomAPI,
|
||||
File,
|
||||
Fixtures,
|
||||
RunMode,
|
||||
Suite,
|
||||
SuiteAPI,
|
||||
SuiteCollector,
|
||||
SuiteFactory,
|
||||
SuiteHooks,
|
||||
Task,
|
||||
TaskCustomOptions,
|
||||
Test,
|
||||
TestAPI,
|
||||
TestFunction,
|
||||
TestOptions,
|
||||
} from './types'
|
||||
import type { VitestRunner } from './types/runner'
|
||||
import { createChainable } from './utils/chain'
|
||||
import { collectTask, collectorContext, createTestContext, runWithSuite, withTimeout } from './context'
|
||||
import {
|
||||
collectTask,
|
||||
collectorContext,
|
||||
createTestContext,
|
||||
runWithSuite,
|
||||
withTimeout,
|
||||
} from './context'
|
||||
import { getHooks, setFixture, setFn, setHooks } from './map'
|
||||
import type { FixtureItem } from './fixture'
|
||||
import { mergeContextFixtures, withFixtures } from './fixture'
|
||||
@ -11,14 +41,24 @@ import { getCurrentTest } from './test-state'
|
||||
|
||||
// apis
|
||||
export const suite = createSuite()
|
||||
export const test = createTest(
|
||||
function (name: string | Function, optionsOrFn?: TestOptions | TestFunction, optionsOrTest?: number | TestOptions | TestFunction) {
|
||||
if (getCurrentTest())
|
||||
throw new Error('Calling the test function inside another test function is not allowed. Please put it inside "describe" or "suite" so it can be properly collected.')
|
||||
export const test = createTest(function (
|
||||
name: string | Function,
|
||||
optionsOrFn?: TestOptions | TestFunction,
|
||||
optionsOrTest?: number | TestOptions | TestFunction,
|
||||
) {
|
||||
if (getCurrentTest()) {
|
||||
throw new Error(
|
||||
'Calling the test function inside another test function is not allowed. Please put it inside "describe" or "suite" so it can be properly collected.',
|
||||
)
|
||||
}
|
||||
|
||||
getCurrentSuite().test.fn.call(this, formatName(name), optionsOrFn as TestOptions, optionsOrTest as TestFunction)
|
||||
},
|
||||
)
|
||||
getCurrentSuite().test.fn.call(
|
||||
this,
|
||||
formatName(name),
|
||||
optionsOrFn as TestOptions,
|
||||
optionsOrTest as TestFunction,
|
||||
)
|
||||
})
|
||||
|
||||
// alias
|
||||
export const describe = suite
|
||||
@ -46,9 +86,13 @@ function createDefaultSuite(runner: VitestRunner) {
|
||||
return api('', { concurrent: config.concurrent }, () => {})
|
||||
}
|
||||
|
||||
export function clearCollectorContext(filepath: string, currentRunner: VitestRunner) {
|
||||
if (!defaultSuite)
|
||||
export function clearCollectorContext(
|
||||
filepath: string,
|
||||
currentRunner: VitestRunner,
|
||||
) {
|
||||
if (!defaultSuite) {
|
||||
defaultSuite = createDefaultSuite(currentRunner)
|
||||
}
|
||||
runner = currentRunner
|
||||
currentTestFilepath = filepath
|
||||
collectorContext.tasks.length = 0
|
||||
@ -57,7 +101,8 @@ export function clearCollectorContext(filepath: string, currentRunner: VitestRun
|
||||
}
|
||||
|
||||
export function getCurrentSuite<ExtraContext = {}>() {
|
||||
return (collectorContext.currentSuite || defaultSuite) as SuiteCollector<ExtraContext>
|
||||
return (collectorContext.currentSuite
|
||||
|| defaultSuite) as SuiteCollector<ExtraContext>
|
||||
}
|
||||
|
||||
export function createSuiteHooks() {
|
||||
@ -79,10 +124,13 @@ function parseArguments<T extends (...args: any[]) => any>(
|
||||
// it('', () => {}, { retry: 2 })
|
||||
if (typeof optionsOrTest === 'object') {
|
||||
// it('', { retry: 2 }, { retry: 3 })
|
||||
if (typeof optionsOrFn === 'object')
|
||||
throw new TypeError('Cannot use two objects as arguments. Please provide options and a function callback in that order.')
|
||||
// TODO: more info, add a name
|
||||
// console.warn('The third argument is deprecated. Please use the second argument for options.')
|
||||
if (typeof optionsOrFn === 'object') {
|
||||
throw new TypeError(
|
||||
'Cannot use two objects as arguments. Please provide options and a function callback in that order.',
|
||||
)
|
||||
}
|
||||
// TODO: more info, add a name
|
||||
// console.warn('The third argument is deprecated. Please use the second argument for options.')
|
||||
options = optionsOrTest
|
||||
}
|
||||
// it('', () => {}, 1000)
|
||||
@ -95,8 +143,11 @@ function parseArguments<T extends (...args: any[]) => any>(
|
||||
}
|
||||
|
||||
if (typeof optionsOrFn === 'function') {
|
||||
if (typeof optionsOrTest === 'function')
|
||||
throw new TypeError('Cannot use two functions as arguments. Please use the second argument for options.')
|
||||
if (typeof optionsOrTest === 'function') {
|
||||
throw new TypeError(
|
||||
'Cannot use two functions as arguments. Please use the second argument for options.',
|
||||
)
|
||||
}
|
||||
fn = optionsOrFn as T
|
||||
}
|
||||
else if (typeof optionsOrTest === 'function') {
|
||||
@ -110,7 +161,14 @@ function parseArguments<T extends (...args: any[]) => any>(
|
||||
}
|
||||
|
||||
// implementations
|
||||
function createSuiteCollector(name: string, factory: SuiteFactory = () => { }, mode: RunMode, shuffle?: boolean, each?: boolean, suiteOptions?: TestOptions) {
|
||||
function createSuiteCollector(
|
||||
name: string,
|
||||
factory: SuiteFactory = () => {},
|
||||
mode: RunMode,
|
||||
shuffle?: boolean,
|
||||
each?: boolean,
|
||||
suiteOptions?: TestOptions,
|
||||
) {
|
||||
const tasks: (Test | Custom | Suite | SuiteCollector)[] = []
|
||||
const factoryQueue: (Test | Suite | SuiteCollector)[] = []
|
||||
|
||||
@ -130,14 +188,25 @@ function createSuiteCollector(name: string, factory: SuiteFactory = () => { }, m
|
||||
file: undefined!,
|
||||
retry: options.retry ?? runner.config.retry,
|
||||
repeats: options.repeats,
|
||||
mode: options.only ? 'only' : options.skip ? 'skip' : options.todo ? 'todo' : 'run',
|
||||
mode: options.only
|
||||
? 'only'
|
||||
: options.skip
|
||||
? 'skip'
|
||||
: options.todo
|
||||
? 'todo'
|
||||
: 'run',
|
||||
meta: options.meta ?? Object.create(null),
|
||||
}
|
||||
const handler = options.handler
|
||||
if (options.concurrent || (!options.sequential && runner.config.sequence.concurrent))
|
||||
if (
|
||||
options.concurrent
|
||||
|| (!options.sequential && runner.config.sequence.concurrent)
|
||||
) {
|
||||
task.concurrent = true
|
||||
if (shuffle)
|
||||
}
|
||||
if (shuffle) {
|
||||
task.shuffle = true
|
||||
}
|
||||
|
||||
const context = createTestContext(task, runner)
|
||||
// create test context
|
||||
@ -148,10 +217,13 @@ function createSuiteCollector(name: string, factory: SuiteFactory = () => { }, m
|
||||
setFixture(context, options.fixtures)
|
||||
|
||||
if (handler) {
|
||||
setFn(task, withTimeout(
|
||||
withFixtures(handler, context),
|
||||
options?.timeout ?? runner.config.testTimeout,
|
||||
))
|
||||
setFn(
|
||||
task,
|
||||
withTimeout(
|
||||
withFixtures(handler, context),
|
||||
options?.timeout ?? runner.config.testTimeout,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (runner.config.includeTaskLocation) {
|
||||
@ -161,32 +233,38 @@ function createSuiteCollector(name: string, factory: SuiteFactory = () => { }, m
|
||||
const error = new Error('stacktrace').stack!
|
||||
Error.stackTraceLimit = limit
|
||||
const stack = findTestFileStackTrace(error, task.each ?? false)
|
||||
if (stack)
|
||||
if (stack) {
|
||||
task.location = stack
|
||||
}
|
||||
}
|
||||
|
||||
tasks.push(task)
|
||||
return task
|
||||
}
|
||||
|
||||
const test = createTest(function (name: string | Function, optionsOrFn?: TestOptions | TestFunction, optionsOrTest?: number | TestOptions | TestFunction) {
|
||||
let { options, handler } = parseArguments(
|
||||
optionsOrFn,
|
||||
optionsOrTest,
|
||||
)
|
||||
const test = createTest(function (
|
||||
name: string | Function,
|
||||
optionsOrFn?: TestOptions | TestFunction,
|
||||
optionsOrTest?: number | TestOptions | TestFunction,
|
||||
) {
|
||||
let { options, handler } = parseArguments(optionsOrFn, optionsOrTest)
|
||||
|
||||
// inherit repeats, retry, timeout from suite
|
||||
if (typeof suiteOptions === 'object')
|
||||
if (typeof suiteOptions === 'object') {
|
||||
options = Object.assign({}, suiteOptions, options)
|
||||
}
|
||||
|
||||
// inherit concurrent / sequential from suite
|
||||
options.concurrent = this.concurrent || (!this.sequential && options?.concurrent)
|
||||
options.sequential = this.sequential || (!this.concurrent && options?.sequential)
|
||||
options.concurrent
|
||||
= this.concurrent || (!this.sequential && options?.concurrent)
|
||||
options.sequential
|
||||
= this.sequential || (!this.concurrent && options?.sequential)
|
||||
|
||||
const test = task(
|
||||
formatName(name),
|
||||
{ ...this, ...options, handler },
|
||||
) as unknown as Test
|
||||
const test = task(formatName(name), {
|
||||
...this,
|
||||
...options,
|
||||
handler,
|
||||
}) as unknown as Test
|
||||
|
||||
test.type = 'test'
|
||||
})
|
||||
@ -205,12 +283,13 @@ function createSuiteCollector(name: string, factory: SuiteFactory = () => { }, m
|
||||
}
|
||||
|
||||
function addHook<T extends keyof SuiteHooks>(name: T, ...fn: SuiteHooks[T]) {
|
||||
getHooks(suite)[name].push(...fn as any)
|
||||
getHooks(suite)[name].push(...(fn as any))
|
||||
}
|
||||
|
||||
function initSuite(includeLocation: boolean) {
|
||||
if (typeof suiteOptions === 'number')
|
||||
if (typeof suiteOptions === 'number') {
|
||||
suiteOptions = { timeout: suiteOptions }
|
||||
}
|
||||
|
||||
suite = {
|
||||
id: '',
|
||||
@ -231,8 +310,9 @@ function createSuiteCollector(name: string, factory: SuiteFactory = () => { }, m
|
||||
const error = new Error('stacktrace').stack!
|
||||
Error.stackTraceLimit = limit
|
||||
const stack = findTestFileStackTrace(error, suite.each ?? false)
|
||||
if (stack)
|
||||
if (stack) {
|
||||
suite.location = stack
|
||||
}
|
||||
}
|
||||
|
||||
setHooks(suite, createSuiteHooks())
|
||||
@ -245,17 +325,20 @@ function createSuiteCollector(name: string, factory: SuiteFactory = () => { }, m
|
||||
}
|
||||
|
||||
async function collect(file: File) {
|
||||
if (!file)
|
||||
if (!file) {
|
||||
throw new TypeError('File is required to collect tasks.')
|
||||
}
|
||||
|
||||
factoryQueue.length = 0
|
||||
if (factory)
|
||||
if (factory) {
|
||||
await runWithSuite(collector, () => factory(test))
|
||||
}
|
||||
|
||||
const allChildren: Task[] = []
|
||||
|
||||
for (const i of [...factoryQueue, ...tasks])
|
||||
for (const i of [...factoryQueue, ...tasks]) {
|
||||
allChildren.push(i.type === 'collector' ? await i.collect(file) : i)
|
||||
}
|
||||
|
||||
suite.file = file
|
||||
suite.tasks = allChildren
|
||||
@ -273,8 +356,19 @@ function createSuiteCollector(name: string, factory: SuiteFactory = () => { }, m
|
||||
}
|
||||
|
||||
function createSuite() {
|
||||
function suiteFn(this: Record<string, boolean | undefined>, name: string | Function, factoryOrOptions?: SuiteFactory | TestOptions, optionsOrFactory: number | TestOptions | SuiteFactory = {}) {
|
||||
const mode: RunMode = this.only ? 'only' : this.skip ? 'skip' : this.todo ? 'todo' : 'run'
|
||||
function suiteFn(
|
||||
this: Record<string, boolean | undefined>,
|
||||
name: string | Function,
|
||||
factoryOrOptions?: SuiteFactory | TestOptions,
|
||||
optionsOrFactory: number | TestOptions | SuiteFactory = {},
|
||||
) {
|
||||
const mode: RunMode = this.only
|
||||
? 'only'
|
||||
: this.skip
|
||||
? 'skip'
|
||||
: this.todo
|
||||
? 'todo'
|
||||
: 'run'
|
||||
const currentSuite = getCurrentSuite()
|
||||
|
||||
let { options, handler: factory } = parseArguments(
|
||||
@ -283,33 +377,52 @@ function createSuite() {
|
||||
)
|
||||
|
||||
// inherit options from current suite
|
||||
if (currentSuite?.options)
|
||||
if (currentSuite?.options) {
|
||||
options = { ...currentSuite.options, ...options }
|
||||
}
|
||||
|
||||
// inherit concurrent / sequential from suite
|
||||
const isConcurrent = options.concurrent || (this.concurrent && !this.sequential)
|
||||
const isSequential = options.sequential || (this.sequential && !this.concurrent)
|
||||
const isConcurrent
|
||||
= options.concurrent || (this.concurrent && !this.sequential)
|
||||
const isSequential
|
||||
= options.sequential || (this.sequential && !this.concurrent)
|
||||
options.concurrent = isConcurrent && !isSequential
|
||||
options.sequential = isSequential && !isConcurrent
|
||||
|
||||
return createSuiteCollector(formatName(name), factory, mode, this.shuffle, this.each, options)
|
||||
return createSuiteCollector(
|
||||
formatName(name),
|
||||
factory,
|
||||
mode,
|
||||
this.shuffle,
|
||||
this.each,
|
||||
options,
|
||||
)
|
||||
}
|
||||
|
||||
suiteFn.each = function<T>(this: { withContext: () => SuiteAPI; setContext: (key: string, value: boolean | undefined) => SuiteAPI }, cases: ReadonlyArray<T>, ...args: any[]) {
|
||||
suiteFn.each = function <T>(
|
||||
this: {
|
||||
withContext: () => SuiteAPI
|
||||
setContext: (key: string, value: boolean | undefined) => SuiteAPI
|
||||
},
|
||||
cases: ReadonlyArray<T>,
|
||||
...args: any[]
|
||||
) {
|
||||
const suite = this.withContext()
|
||||
this.setContext('each', true)
|
||||
|
||||
if (Array.isArray(cases) && args.length)
|
||||
if (Array.isArray(cases) && args.length) {
|
||||
cases = formatTemplateString(cases, args)
|
||||
}
|
||||
|
||||
return (name: string | Function, optionsOrFn: ((...args: T[]) => void) | TestOptions, fnOrOptions?: ((...args: T[]) => void) | number | TestOptions) => {
|
||||
return (
|
||||
name: string | Function,
|
||||
optionsOrFn: ((...args: T[]) => void) | TestOptions,
|
||||
fnOrOptions?: ((...args: T[]) => void) | number | TestOptions,
|
||||
) => {
|
||||
const _name = formatName(name)
|
||||
const arrayOnlyCases = cases.every(Array.isArray)
|
||||
|
||||
const { options, handler } = parseArguments(
|
||||
optionsOrFn,
|
||||
fnOrOptions,
|
||||
)
|
||||
const { options, handler } = parseArguments(optionsOrFn, fnOrOptions)
|
||||
|
||||
const fnFirst = typeof optionsOrFn === 'function'
|
||||
|
||||
@ -317,12 +430,17 @@ function createSuite() {
|
||||
const items = Array.isArray(i) ? i : [i]
|
||||
if (fnFirst) {
|
||||
arrayOnlyCases
|
||||
? suite(formatTitle(_name, items, idx), () => handler(...items), options)
|
||||
? suite(
|
||||
formatTitle(_name, items, idx),
|
||||
() => handler(...items),
|
||||
options,
|
||||
)
|
||||
: suite(formatTitle(_name, items, idx), () => handler(i), options)
|
||||
}
|
||||
else {
|
||||
arrayOnlyCases
|
||||
? suite(formatTitle(_name, items, idx), options, () => handler(...items))
|
||||
? suite(formatTitle(_name, items, idx), options, () =>
|
||||
handler(...items))
|
||||
: suite(formatTitle(_name, items, idx), options, () => handler(i))
|
||||
}
|
||||
})
|
||||
@ -331,8 +449,10 @@ function createSuite() {
|
||||
}
|
||||
}
|
||||
|
||||
suiteFn.skipIf = (condition: any) => (condition ? suite.skip : suite) as SuiteAPI
|
||||
suiteFn.runIf = (condition: any) => (condition ? suite : suite.skip) as SuiteAPI
|
||||
suiteFn.skipIf = (condition: any) =>
|
||||
(condition ? suite.skip : suite) as SuiteAPI
|
||||
suiteFn.runIf = (condition: any) =>
|
||||
(condition ? suite : suite.skip) as SuiteAPI
|
||||
|
||||
return createChainable(
|
||||
['concurrent', 'sequential', 'shuffle', 'skip', 'only', 'todo'],
|
||||
@ -346,21 +466,30 @@ export function createTaskCollector(
|
||||
) {
|
||||
const taskFn = fn as any
|
||||
|
||||
taskFn.each = function<T>(this: { withContext: () => SuiteAPI; setContext: (key: string, value: boolean | undefined) => SuiteAPI }, cases: ReadonlyArray<T>, ...args: any[]) {
|
||||
taskFn.each = function <T>(
|
||||
this: {
|
||||
withContext: () => SuiteAPI
|
||||
setContext: (key: string, value: boolean | undefined) => SuiteAPI
|
||||
},
|
||||
cases: ReadonlyArray<T>,
|
||||
...args: any[]
|
||||
) {
|
||||
const test = this.withContext()
|
||||
this.setContext('each', true)
|
||||
|
||||
if (Array.isArray(cases) && args.length)
|
||||
if (Array.isArray(cases) && args.length) {
|
||||
cases = formatTemplateString(cases, args)
|
||||
}
|
||||
|
||||
return (name: string | Function, optionsOrFn: ((...args: T[]) => void) | TestOptions, fnOrOptions?: ((...args: T[]) => void) | number | TestOptions) => {
|
||||
return (
|
||||
name: string | Function,
|
||||
optionsOrFn: ((...args: T[]) => void) | TestOptions,
|
||||
fnOrOptions?: ((...args: T[]) => void) | number | TestOptions,
|
||||
) => {
|
||||
const _name = formatName(name)
|
||||
const arrayOnlyCases = cases.every(Array.isArray)
|
||||
|
||||
const { options, handler } = parseArguments(
|
||||
optionsOrFn,
|
||||
fnOrOptions,
|
||||
)
|
||||
const { options, handler } = parseArguments(optionsOrFn, fnOrOptions)
|
||||
|
||||
const fnFirst = typeof optionsOrFn === 'function'
|
||||
|
||||
@ -369,12 +498,17 @@ export function createTaskCollector(
|
||||
|
||||
if (fnFirst) {
|
||||
arrayOnlyCases
|
||||
? test(formatTitle(_name, items, idx), () => handler(...items), options)
|
||||
? test(
|
||||
formatTitle(_name, items, idx),
|
||||
() => handler(...items),
|
||||
options,
|
||||
)
|
||||
: test(formatTitle(_name, items, idx), () => handler(i), options)
|
||||
}
|
||||
else {
|
||||
arrayOnlyCases
|
||||
? test(formatTitle(_name, items, idx), options, () => handler(...items))
|
||||
? test(formatTitle(_name, items, idx), options, () =>
|
||||
handler(...items))
|
||||
: test(formatTitle(_name, items, idx), options, () => handler(i))
|
||||
}
|
||||
})
|
||||
@ -393,8 +527,9 @@ export function createTaskCollector(
|
||||
) {
|
||||
const test = this.withContext()
|
||||
|
||||
if (Array.isArray(cases) && args.length)
|
||||
if (Array.isArray(cases) && args.length) {
|
||||
cases = formatTemplateString(cases, args)
|
||||
}
|
||||
|
||||
return (
|
||||
name: string | Function,
|
||||
@ -423,8 +558,17 @@ export function createTaskCollector(
|
||||
taskFn.extend = function (fixtures: Fixtures<Record<string, any>>) {
|
||||
const _context = mergeContextFixtures(fixtures, context)
|
||||
|
||||
return createTest(function fn(name: string | Function, optionsOrFn?: TestOptions | TestFunction, optionsOrTest?: number | TestOptions | TestFunction) {
|
||||
getCurrentSuite().test.fn.call(this, formatName(name), optionsOrFn as TestOptions, optionsOrTest as TestFunction)
|
||||
return createTest(function fn(
|
||||
name: string | Function,
|
||||
optionsOrFn?: TestOptions | TestFunction,
|
||||
optionsOrTest?: number | TestOptions | TestFunction,
|
||||
) {
|
||||
getCurrentSuite().test.fn.call(
|
||||
this,
|
||||
formatName(name),
|
||||
optionsOrFn as TestOptions,
|
||||
optionsOrTest as TestFunction,
|
||||
)
|
||||
}, _context)
|
||||
}
|
||||
|
||||
@ -433,25 +577,34 @@ export function createTaskCollector(
|
||||
taskFn,
|
||||
) as CustomAPI
|
||||
|
||||
if (context)
|
||||
if (context) {
|
||||
(_test as any).mergeContext(context)
|
||||
}
|
||||
|
||||
return _test
|
||||
}
|
||||
|
||||
function createTest(fn: (
|
||||
(
|
||||
this: Record<'concurrent' | 'sequential' | 'skip' | 'only' | 'todo' | 'fails' | 'each', boolean | undefined> & { fixtures?: FixtureItem[] },
|
||||
function createTest(
|
||||
fn: (
|
||||
this: Record<
|
||||
'concurrent' | 'sequential' | 'skip' | 'only' | 'todo' | 'fails' | 'each',
|
||||
boolean | undefined
|
||||
> & { fixtures?: FixtureItem[] },
|
||||
title: string,
|
||||
optionsOrFn?: TestOptions | TestFunction,
|
||||
optionsOrTest?: number | TestOptions | TestFunction,
|
||||
) => void
|
||||
), context?: Record<string, any>) {
|
||||
optionsOrTest?: number | TestOptions | TestFunction
|
||||
) => void,
|
||||
context?: Record<string, any>,
|
||||
) {
|
||||
return createTaskCollector(fn, context) as TestAPI
|
||||
}
|
||||
|
||||
function formatName(name: string | Function) {
|
||||
return typeof name === 'string' ? name : name instanceof Function ? (name.name || '<anonymous>') : String(name)
|
||||
return typeof name === 'string'
|
||||
? name
|
||||
: name instanceof Function
|
||||
? name.name || '<anonymous>'
|
||||
: String(name)
|
||||
}
|
||||
|
||||
function formatTitle(template: string, items: any[], idx: number) {
|
||||
@ -483,19 +636,28 @@ function formatTitle(template: string, items: any[], idx: number) {
|
||||
formatted = formatted.replace(
|
||||
/\$([$\w.]+)/g,
|
||||
// https://github.com/chaijs/chai/pull/1490
|
||||
(_, key) => objDisplay(objectAttr(items[0], key), { truncate: runner?.config?.chaiConfig?.truncateThreshold }) as unknown as string,
|
||||
(_, key) =>
|
||||
objDisplay(objectAttr(items[0], key), {
|
||||
truncate: runner?.config?.chaiConfig?.truncateThreshold,
|
||||
}) as unknown as string,
|
||||
)
|
||||
}
|
||||
return formatted
|
||||
}
|
||||
|
||||
function formatTemplateString(cases: any[], args: any[]): any[] {
|
||||
const header = cases.join('').trim().replace(/ /g, '').split('\n').map(i => i.split('|'))[0]
|
||||
const header = cases
|
||||
.join('')
|
||||
.trim()
|
||||
.replace(/ /g, '')
|
||||
.split('\n')
|
||||
.map(i => i.split('|'))[0]
|
||||
const res: any[] = []
|
||||
for (let i = 0; i < Math.floor((args.length) / header.length); i++) {
|
||||
for (let i = 0; i < Math.floor(args.length / header.length); i++) {
|
||||
const oneCase: Record<string, any> = {}
|
||||
for (let j = 0; j < header.length; j++)
|
||||
for (let j = 0; j < header.length; j++) {
|
||||
oneCase[header[j]] = args[i * header.length + j] as any
|
||||
}
|
||||
res.push(oneCase)
|
||||
}
|
||||
return res
|
||||
|
||||
@ -40,13 +40,13 @@ export interface VitestRunnerConfig {
|
||||
export type VitestRunnerImportSource = 'collect' | 'setup'
|
||||
|
||||
export interface VitestRunnerConstructor {
|
||||
new(config: VitestRunnerConfig): VitestRunner
|
||||
new (config: VitestRunnerConfig): VitestRunner
|
||||
}
|
||||
|
||||
export type CancelReason =
|
||||
| 'keyboard-input'
|
||||
| 'test-failure'
|
||||
| string & Record<string, never>
|
||||
| (string & Record<string, never>)
|
||||
|
||||
export interface VitestRunner {
|
||||
/**
|
||||
@ -76,7 +76,10 @@ export interface VitestRunner {
|
||||
/**
|
||||
* Called before actually running the test function. Already has "result" with "state" and "startTime".
|
||||
*/
|
||||
onBeforeTryTask?: (test: Task, options: { retry: number; repeats: number }) => unknown
|
||||
onBeforeTryTask?: (
|
||||
test: Task,
|
||||
options: { retry: number; repeats: number }
|
||||
) => unknown
|
||||
/**
|
||||
* Called after result and state are set.
|
||||
*/
|
||||
|
||||
@ -50,7 +50,11 @@ export interface TaskResult {
|
||||
repeatCount?: number
|
||||
}
|
||||
|
||||
export type TaskResultPack = [id: string, result: TaskResult | undefined, meta: TaskMeta]
|
||||
export type TaskResultPack = [
|
||||
id: string,
|
||||
result: TaskResult | undefined,
|
||||
meta: TaskMeta,
|
||||
]
|
||||
|
||||
export interface Suite extends TaskBase {
|
||||
file: File
|
||||
@ -78,7 +82,9 @@ export interface Custom<ExtraContext = {}> extends TaskPopulated {
|
||||
export type Task = Test | Suite | Custom | File
|
||||
|
||||
export type DoneCallback = (error?: any) => void
|
||||
export type TestFunction<ExtraContext = {}> = (context: ExtendedContext<Test> & ExtraContext) => Awaitable<any> | void
|
||||
export type TestFunction<ExtraContext = {}> = (
|
||||
context: ExtendedContext<Test> & ExtraContext
|
||||
) => Awaitable<any> | void
|
||||
|
||||
// jest's ExtractEachCallbackArgs
|
||||
type ExtractEachCallbackArgs<T extends ReadonlyArray<any>> = {
|
||||
@ -122,23 +128,25 @@ interface EachFunctionReturn<T extends any[]> {
|
||||
(
|
||||
name: string | Function,
|
||||
fn: (...args: T) => Awaitable<void>,
|
||||
options: TestOptions,
|
||||
options: TestOptions
|
||||
): void
|
||||
(
|
||||
name: string | Function,
|
||||
fn: (...args: T) => Awaitable<void>,
|
||||
options?: number | TestOptions,
|
||||
options?: number | TestOptions
|
||||
): void
|
||||
(
|
||||
name: string | Function,
|
||||
options: TestOptions,
|
||||
fn: (...args: T) => Awaitable<void>,
|
||||
fn: (...args: T) => Awaitable<void>
|
||||
): void
|
||||
}
|
||||
|
||||
interface TestEachFunction {
|
||||
<T extends any[] | [any]>(cases: ReadonlyArray<T>): EachFunctionReturn<T>
|
||||
<T extends ReadonlyArray<any>>(cases: ReadonlyArray<T>): EachFunctionReturn<ExtractEachCallbackArgs<T>>
|
||||
<T extends ReadonlyArray<any>>(cases: ReadonlyArray<T>): EachFunctionReturn<
|
||||
ExtractEachCallbackArgs<T>
|
||||
>
|
||||
<T>(cases: ReadonlyArray<T>): EachFunctionReturn<T[]>
|
||||
(...args: [TemplateStringsArray, ...any]): EachFunctionReturn<any[]>
|
||||
}
|
||||
@ -146,35 +154,53 @@ interface TestEachFunction {
|
||||
interface TestForFunctionReturn<Arg, Context> {
|
||||
(
|
||||
name: string | Function,
|
||||
fn: (arg: Arg, context: Context) => Awaitable<void>,
|
||||
fn: (arg: Arg, context: Context) => Awaitable<void>
|
||||
): void
|
||||
(
|
||||
name: string | Function,
|
||||
options: TestOptions,
|
||||
fn: (args: Arg, context: Context) => Awaitable<void>,
|
||||
fn: (args: Arg, context: Context) => Awaitable<void>
|
||||
): void
|
||||
}
|
||||
|
||||
interface TestForFunction<ExtraContext> {
|
||||
// test.for([1, 2, 3])
|
||||
// test.for([[1, 2], [3, 4, 5]])
|
||||
<T>(cases: ReadonlyArray<T>): TestForFunctionReturn<T, ExtendedContext<Test> & ExtraContext>
|
||||
<T>(cases: ReadonlyArray<T>): TestForFunctionReturn<
|
||||
T,
|
||||
ExtendedContext<Test> & ExtraContext
|
||||
>
|
||||
|
||||
// test.for`
|
||||
// a | b
|
||||
// {1} | {2}
|
||||
// {3} | {4}
|
||||
// `
|
||||
(strings: TemplateStringsArray, ...values: any[]): TestForFunctionReturn<any, ExtendedContext<Test> & ExtraContext>
|
||||
(strings: TemplateStringsArray, ...values: any[]): TestForFunctionReturn<
|
||||
any,
|
||||
ExtendedContext<Test> & ExtraContext
|
||||
>
|
||||
}
|
||||
|
||||
interface TestCollectorCallable<C = {}> {
|
||||
/**
|
||||
* @deprecated Use options as the second argument instead
|
||||
*/
|
||||
<ExtraContext extends C>(name: string | Function, fn: TestFunction<ExtraContext>, options: TestOptions): void
|
||||
<ExtraContext extends C>(name: string | Function, fn?: TestFunction<ExtraContext>, options?: number | TestOptions): void
|
||||
<ExtraContext extends C>(name: string | Function, options?: TestOptions, fn?: TestFunction<ExtraContext>): void
|
||||
<ExtraContext extends C>(
|
||||
name: string | Function,
|
||||
fn: TestFunction<ExtraContext>,
|
||||
options: TestOptions
|
||||
): void
|
||||
<ExtraContext extends C>(
|
||||
name: string | Function,
|
||||
fn?: TestFunction<ExtraContext>,
|
||||
options?: number | TestOptions
|
||||
): void
|
||||
<ExtraContext extends C>(
|
||||
name: string | Function,
|
||||
options?: TestOptions,
|
||||
fn?: TestFunction<ExtraContext>
|
||||
): void
|
||||
}
|
||||
|
||||
type ChainableTestAPI<ExtraContext = {}> = ChainableFunction<
|
||||
@ -238,19 +264,31 @@ interface ExtendedAPI<ExtraContext> {
|
||||
runIf: (condition: any) => ChainableTestAPI<ExtraContext>
|
||||
}
|
||||
|
||||
export type CustomAPI<ExtraContext = {}> = ChainableTestAPI<ExtraContext> & ExtendedAPI<ExtraContext> & {
|
||||
extend: <T extends Record<string, any> = {}>(fixtures: Fixtures<T, ExtraContext>) => CustomAPI<{
|
||||
[K in keyof T | keyof ExtraContext]:
|
||||
K extends keyof T ? T[K] :
|
||||
K extends keyof ExtraContext ? ExtraContext[K] : never }>
|
||||
}
|
||||
export type CustomAPI<ExtraContext = {}> = ChainableTestAPI<ExtraContext> &
|
||||
ExtendedAPI<ExtraContext> & {
|
||||
extend: <T extends Record<string, any> = {}>(
|
||||
fixtures: Fixtures<T, ExtraContext>
|
||||
) => CustomAPI<{
|
||||
[K in keyof T | keyof ExtraContext]: K extends keyof T
|
||||
? T[K]
|
||||
: K extends keyof ExtraContext
|
||||
? ExtraContext[K]
|
||||
: never;
|
||||
}>
|
||||
}
|
||||
|
||||
export type TestAPI<ExtraContext = {}> = ChainableTestAPI<ExtraContext> & ExtendedAPI<ExtraContext> & {
|
||||
extend: <T extends Record<string, any> = {}>(fixtures: Fixtures<T, ExtraContext>) => TestAPI<{
|
||||
[K in keyof T | keyof ExtraContext]:
|
||||
K extends keyof T ? T[K] :
|
||||
K extends keyof ExtraContext ? ExtraContext[K] : never }>
|
||||
}
|
||||
export type TestAPI<ExtraContext = {}> = ChainableTestAPI<ExtraContext> &
|
||||
ExtendedAPI<ExtraContext> & {
|
||||
extend: <T extends Record<string, any> = {}>(
|
||||
fixtures: Fixtures<T, ExtraContext>
|
||||
) => TestAPI<{
|
||||
[K in keyof T | keyof ExtraContext]: K extends keyof T
|
||||
? T[K]
|
||||
: K extends keyof ExtraContext
|
||||
? ExtraContext[K]
|
||||
: never;
|
||||
}>
|
||||
}
|
||||
|
||||
export interface FixtureOptions {
|
||||
/**
|
||||
@ -260,14 +298,25 @@ export interface FixtureOptions {
|
||||
}
|
||||
|
||||
export type Use<T> = (value: T) => Promise<void>
|
||||
export type FixtureFn<T, K extends keyof T, ExtraContext> =
|
||||
(context: Omit<T, K> & ExtraContext, use: Use<T[K]>) => Promise<void>
|
||||
export type Fixture<T, K extends keyof T, ExtraContext = {}> =
|
||||
((...args: any) => any) extends T[K]
|
||||
? (T[K] extends any ? FixtureFn<T, K, Omit<ExtraContext, Exclude<keyof T, K>>> : never)
|
||||
: T[K] | (T[K] extends any ? FixtureFn<T, K, Omit<ExtraContext, Exclude<keyof T, K>>> : never)
|
||||
export type FixtureFn<T, K extends keyof T, ExtraContext> = (
|
||||
context: Omit<T, K> & ExtraContext,
|
||||
use: Use<T[K]>
|
||||
) => Promise<void>
|
||||
export type Fixture<T, K extends keyof T, ExtraContext = {}> = ((
|
||||
...args: any
|
||||
) => any) extends T[K]
|
||||
? T[K] extends any
|
||||
? FixtureFn<T, K, Omit<ExtraContext, Exclude<keyof T, K>>>
|
||||
: never
|
||||
:
|
||||
| T[K]
|
||||
| (T[K] extends any
|
||||
? FixtureFn<T, K, Omit<ExtraContext, Exclude<keyof T, K>>>
|
||||
: never)
|
||||
export type Fixtures<T extends Record<string, any>, ExtraContext = {}> = {
|
||||
[K in keyof T]: Fixture<T, K, ExtraContext & ExtendedContext<Test>> | [Fixture<T, K, ExtraContext & ExtendedContext<Test>>, FixtureOptions?]
|
||||
[K in keyof T]:
|
||||
| Fixture<T, K, ExtraContext & ExtendedContext<Test>>
|
||||
| [Fixture<T, K, ExtraContext & ExtendedContext<Test>>, FixtureOptions?];
|
||||
}
|
||||
|
||||
export type InferFixturesTypes<T> = T extends TestAPI<infer C> ? C : T
|
||||
@ -276,9 +325,21 @@ interface SuiteCollectorCallable<ExtraContext = {}> {
|
||||
/**
|
||||
* @deprecated Use options as the second argument instead
|
||||
*/
|
||||
<OverrideExtraContext extends ExtraContext = ExtraContext>(name: string | Function, fn: SuiteFactory<OverrideExtraContext>, options: TestOptions): SuiteCollector<OverrideExtraContext>
|
||||
<OverrideExtraContext extends ExtraContext = ExtraContext>(name: string | Function, fn?: SuiteFactory<OverrideExtraContext>, options?: number | TestOptions): SuiteCollector<OverrideExtraContext>
|
||||
<OverrideExtraContext extends ExtraContext = ExtraContext>(name: string | Function, options: TestOptions, fn?: SuiteFactory<OverrideExtraContext>): SuiteCollector<OverrideExtraContext>
|
||||
<OverrideExtraContext extends ExtraContext = ExtraContext>(
|
||||
name: string | Function,
|
||||
fn: SuiteFactory<OverrideExtraContext>,
|
||||
options: TestOptions
|
||||
): SuiteCollector<OverrideExtraContext>
|
||||
<OverrideExtraContext extends ExtraContext = ExtraContext>(
|
||||
name: string | Function,
|
||||
fn?: SuiteFactory<OverrideExtraContext>,
|
||||
options?: number | TestOptions
|
||||
): SuiteCollector<OverrideExtraContext>
|
||||
<OverrideExtraContext extends ExtraContext = ExtraContext>(
|
||||
name: string | Function,
|
||||
options: TestOptions,
|
||||
fn?: SuiteFactory<OverrideExtraContext>
|
||||
): SuiteCollector<OverrideExtraContext>
|
||||
}
|
||||
|
||||
type ChainableSuiteAPI<ExtraContext = {}> = ChainableFunction<
|
||||
@ -294,15 +355,22 @@ export type SuiteAPI<ExtraContext = {}> = ChainableSuiteAPI<ExtraContext> & {
|
||||
runIf: (condition: any) => ChainableSuiteAPI<ExtraContext>
|
||||
}
|
||||
|
||||
export type HookListener<T extends any[], Return = void> = (...args: T) => Awaitable<Return>
|
||||
export type HookListener<T extends any[], Return = void> = (
|
||||
...args: T
|
||||
) => Awaitable<Return>
|
||||
|
||||
export type HookCleanupCallback = (() => Awaitable<unknown>) | void
|
||||
|
||||
export interface SuiteHooks<ExtraContext = {}> {
|
||||
beforeAll: HookListener<[Readonly<Suite | File>], HookCleanupCallback>[]
|
||||
afterAll: HookListener<[Readonly<Suite | File>]>[]
|
||||
beforeEach: HookListener<[ExtendedContext<Test | Custom> & ExtraContext, Readonly<Suite>], HookCleanupCallback>[]
|
||||
afterEach: HookListener<[ExtendedContext<Test | Custom> & ExtraContext, Readonly<Suite>]>[]
|
||||
beforeEach: HookListener<
|
||||
[ExtendedContext<Test | Custom> & ExtraContext, Readonly<Suite>],
|
||||
HookCleanupCallback
|
||||
>[]
|
||||
afterEach: HookListener<
|
||||
[ExtendedContext<Test | Custom> & ExtraContext, Readonly<Suite>]
|
||||
>[]
|
||||
}
|
||||
|
||||
export interface TaskCustomOptions extends TestOptions {
|
||||
@ -324,14 +392,24 @@ export interface SuiteCollector<ExtraContext = {}> {
|
||||
options?: TestOptions
|
||||
type: 'collector'
|
||||
test: TestAPI<ExtraContext>
|
||||
tasks: (Suite | Custom<ExtraContext> | Test<ExtraContext> | SuiteCollector<ExtraContext>)[]
|
||||
tasks: (
|
||||
| Suite
|
||||
| Custom<ExtraContext>
|
||||
| Test<ExtraContext>
|
||||
| SuiteCollector<ExtraContext>
|
||||
)[]
|
||||
task: (name: string, options?: TaskCustomOptions) => Custom<ExtraContext>
|
||||
collect: (file: File) => Promise<Suite>
|
||||
clear: () => void
|
||||
on: <T extends keyof SuiteHooks<ExtraContext>>(name: T, ...fn: SuiteHooks<ExtraContext>[T]) => void
|
||||
on: <T extends keyof SuiteHooks<ExtraContext>>(
|
||||
name: T,
|
||||
...fn: SuiteHooks<ExtraContext>[T]
|
||||
) => void
|
||||
}
|
||||
|
||||
export type SuiteFactory<ExtraContext = {}> = (test: TestAPI<ExtraContext>) => Awaitable<void>
|
||||
export type SuiteFactory<ExtraContext = {}> = (
|
||||
test: TestAPI<ExtraContext>
|
||||
) => Awaitable<void>
|
||||
|
||||
export interface RuntimeContext {
|
||||
tasks: (SuiteCollector | Test)[]
|
||||
@ -362,7 +440,8 @@ export interface TaskContext<Task extends Custom | Test = Custom | Test> {
|
||||
skip: () => void
|
||||
}
|
||||
|
||||
export type ExtendedContext<T extends Custom | Test> = TaskContext<T> & TestContext
|
||||
export type ExtendedContext<T extends Custom | Test> = TaskContext<T> &
|
||||
TestContext
|
||||
|
||||
export type OnTestFailedHandler = (result: TaskResult) => Awaitable<void>
|
||||
export type OnTestFinishedHandler = (result: TaskResult) => Awaitable<void>
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
export type ChainableFunction<T extends string, F extends (...args: any) => any, C = {}> = F & {
|
||||
[x in T]: ChainableFunction<T, F, C>
|
||||
export type ChainableFunction<
|
||||
T extends string,
|
||||
F extends (...args: any) => any,
|
||||
C = {},
|
||||
> = F & {
|
||||
[x in T]: ChainableFunction<T, F, C>;
|
||||
} & {
|
||||
fn: (this: Record<T, any>, ...args: Parameters<F>) => ReturnType<F>
|
||||
} & C
|
||||
|
||||
@ -5,7 +5,13 @@ import type { File, Suite, TaskBase } from '../types'
|
||||
/**
|
||||
* If any tasks been marked as `only`, mark all other tasks as `skip`.
|
||||
*/
|
||||
export function interpretTaskModes(suite: Suite, namePattern?: string | RegExp, onlyMode?: boolean, parentIsOnly?: boolean, allowOnly?: boolean) {
|
||||
export function interpretTaskModes(
|
||||
suite: Suite,
|
||||
namePattern?: string | RegExp,
|
||||
onlyMode?: boolean,
|
||||
parentIsOnly?: boolean,
|
||||
allowOnly?: boolean,
|
||||
) {
|
||||
const suiteIsOnly = parentIsOnly || suite.mode === 'only'
|
||||
|
||||
suite.tasks.forEach((t) => {
|
||||
@ -28,21 +34,25 @@ export function interpretTaskModes(suite: Suite, namePattern?: string | RegExp,
|
||||
}
|
||||
}
|
||||
if (t.type === 'test') {
|
||||
if (namePattern && !getTaskFullName(t).match(namePattern))
|
||||
if (namePattern && !getTaskFullName(t).match(namePattern)) {
|
||||
t.mode = 'skip'
|
||||
}
|
||||
}
|
||||
else if (t.type === 'suite') {
|
||||
if (t.mode === 'skip')
|
||||
if (t.mode === 'skip') {
|
||||
skipAllTasks(t)
|
||||
else
|
||||
}
|
||||
else {
|
||||
interpretTaskModes(t, namePattern, onlyMode, includeTask, allowOnly)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// if all subtasks are skipped, mark as skip
|
||||
if (suite.mode === 'run') {
|
||||
if (suite.tasks.length && suite.tasks.every(i => i.mode !== 'run'))
|
||||
if (suite.tasks.length && suite.tasks.every(i => i.mode !== 'run')) {
|
||||
suite.mode = 'skip'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,23 +61,31 @@ function getTaskFullName(task: TaskBase): string {
|
||||
}
|
||||
|
||||
export function someTasksAreOnly(suite: Suite): boolean {
|
||||
return suite.tasks.some(t => t.mode === 'only' || (t.type === 'suite' && someTasksAreOnly(t)))
|
||||
return suite.tasks.some(
|
||||
t => t.mode === 'only' || (t.type === 'suite' && someTasksAreOnly(t)),
|
||||
)
|
||||
}
|
||||
|
||||
function skipAllTasks(suite: Suite) {
|
||||
suite.tasks.forEach((t) => {
|
||||
if (t.mode === 'run') {
|
||||
t.mode = 'skip'
|
||||
if (t.type === 'suite')
|
||||
if (t.type === 'suite') {
|
||||
skipAllTasks(t)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function checkAllowOnly(task: TaskBase, allowOnly?: boolean) {
|
||||
if (allowOnly)
|
||||
if (allowOnly) {
|
||||
return
|
||||
const error = processError(new Error('[Vitest] Unexpected .only modifier. Remove it or pass --allowOnly argument to bypass this error'))
|
||||
}
|
||||
const error = processError(
|
||||
new Error(
|
||||
'[Vitest] Unexpected .only modifier. Remove it or pass --allowOnly argument to bypass this error',
|
||||
),
|
||||
)
|
||||
task.result = {
|
||||
state: 'fail',
|
||||
errors: [error],
|
||||
@ -76,8 +94,9 @@ function checkAllowOnly(task: TaskBase, allowOnly?: boolean) {
|
||||
|
||||
export function generateHash(str: string): string {
|
||||
let hash = 0
|
||||
if (str.length === 0)
|
||||
if (str.length === 0) {
|
||||
return `${hash}`
|
||||
}
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i)
|
||||
hash = (hash << 5) - hash + char
|
||||
@ -89,12 +108,17 @@ export function generateHash(str: string): string {
|
||||
export function calculateSuiteHash(parent: Suite) {
|
||||
parent.tasks.forEach((t, idx) => {
|
||||
t.id = `${parent.id}_${idx}`
|
||||
if (t.type === 'suite')
|
||||
if (t.type === 'suite') {
|
||||
calculateSuiteHash(t)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function createFileTask(filepath: string, root: string, projectName: string) {
|
||||
export function createFileTask(
|
||||
filepath: string,
|
||||
root: string,
|
||||
projectName: string,
|
||||
) {
|
||||
const path = relative(root, filepath)
|
||||
const file: File = {
|
||||
id: generateHash(`${path}${projectName || ''}`),
|
||||
|
||||
@ -15,8 +15,9 @@ export function partitionSuiteChildren(suite: Suite) {
|
||||
tasksGroup = [c]
|
||||
}
|
||||
}
|
||||
if (tasksGroup.length > 0)
|
||||
if (tasksGroup.length > 0) {
|
||||
tasksGroups.push(tasksGroup)
|
||||
}
|
||||
|
||||
return tasksGroups
|
||||
}
|
||||
|
||||
@ -19,8 +19,9 @@ export function getTests(suite: Arrayable<Task>): (Test | Custom)[] {
|
||||
}
|
||||
else {
|
||||
const taskTests = getTests(task)
|
||||
for (const test of taskTests)
|
||||
for (const test of taskTests) {
|
||||
tests.push(test)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -29,19 +30,28 @@ export function getTests(suite: Arrayable<Task>): (Test | Custom)[] {
|
||||
}
|
||||
|
||||
export function getTasks(tasks: Arrayable<Task> = []): Task[] {
|
||||
return toArray(tasks).flatMap(s => isAtomTest(s) ? [s] : [s, ...getTasks(s.tasks)])
|
||||
return toArray(tasks).flatMap(s =>
|
||||
isAtomTest(s) ? [s] : [s, ...getTasks(s.tasks)],
|
||||
)
|
||||
}
|
||||
|
||||
export function getSuites(suite: Arrayable<Task>): Suite[] {
|
||||
return toArray(suite).flatMap(s => s.type === 'suite' ? [s, ...getSuites(s.tasks)] : [])
|
||||
return toArray(suite).flatMap(s =>
|
||||
s.type === 'suite' ? [s, ...getSuites(s.tasks)] : [],
|
||||
)
|
||||
}
|
||||
|
||||
export function hasTests(suite: Arrayable<Suite>): boolean {
|
||||
return toArray(suite).some(s => s.tasks.some(c => isAtomTest(c) || hasTests(c)))
|
||||
return toArray(suite).some(s =>
|
||||
s.tasks.some(c => isAtomTest(c) || hasTests(c)),
|
||||
)
|
||||
}
|
||||
|
||||
export function hasFailed(suite: Arrayable<Task>): boolean {
|
||||
return toArray(suite).some(s => s.result?.state === 'fail' || (s.type === 'suite' && hasFailed(s.tasks)))
|
||||
return toArray(suite).some(
|
||||
s =>
|
||||
s.result?.state === 'fail' || (s.type === 'suite' && hasFailed(s.tasks)),
|
||||
)
|
||||
}
|
||||
|
||||
export function getNames(task: Task) {
|
||||
@ -50,12 +60,14 @@ export function getNames(task: Task) {
|
||||
|
||||
while (current?.suite) {
|
||||
current = current.suite
|
||||
if (current?.name)
|
||||
if (current?.name) {
|
||||
names.unshift(current.name)
|
||||
}
|
||||
}
|
||||
|
||||
if (current !== task.file)
|
||||
if (current !== task.file) {
|
||||
names.unshift(task.file.name)
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
@ -12,7 +12,8 @@ import { SnapshotManager } from '@vitest/snapshot/manager'
|
||||
const client = new SnapshotClient({
|
||||
// you need to provide your own equality check implementation if you use it
|
||||
// this function is called when `.toMatchSnapshot({ property: 1 })` is called
|
||||
isEqual: (received, expected) => equals(received, expected, [iterableEquality, subsetEquality]),
|
||||
isEqual: (received, expected) =>
|
||||
equals(received, expected, [iterableEquality, subsetEquality]),
|
||||
})
|
||||
|
||||
// class that implements snapshot saving and reading
|
||||
@ -53,7 +54,11 @@ const options = {
|
||||
snapshotEnvironment: environment,
|
||||
}
|
||||
|
||||
await client.startCurrentRun(getCurrentFilepath(), getCurrentTestName(), options)
|
||||
await client.startCurrentRun(
|
||||
getCurrentFilepath(),
|
||||
getCurrentTestName(),
|
||||
options
|
||||
)
|
||||
|
||||
// this will save snapshot to a file which is returned by "snapshotEnvironment.resolvePath"
|
||||
client.assert({
|
||||
|
||||
@ -51,15 +51,14 @@ export default defineConfig([
|
||||
format: 'esm',
|
||||
},
|
||||
external,
|
||||
plugins: [
|
||||
dts({ respectExternal: true }),
|
||||
],
|
||||
plugins: [dts({ respectExternal: true })],
|
||||
onwarn,
|
||||
},
|
||||
])
|
||||
|
||||
function onwarn(message) {
|
||||
if (['EMPTY_BUNDLE', 'CIRCULAR_DEPENDENCY'].includes(message.code))
|
||||
if (['EMPTY_BUNDLE', 'CIRCULAR_DEPENDENCY'].includes(message.code)) {
|
||||
return
|
||||
}
|
||||
console.error(message)
|
||||
}
|
||||
|
||||
@ -3,7 +3,12 @@ import SnapshotState from './port/state'
|
||||
import type { SnapshotStateOptions } from './types'
|
||||
import type { RawSnapshotInfo } from './port/rawSnapshot'
|
||||
|
||||
function createMismatchError(message: string, expand: boolean | undefined, actual: unknown, expected: unknown) {
|
||||
function createMismatchError(
|
||||
message: string,
|
||||
expand: boolean | undefined,
|
||||
actual: unknown,
|
||||
expected: unknown,
|
||||
) {
|
||||
const error = new Error(message)
|
||||
Object.defineProperty(error, 'actual', {
|
||||
value: actual,
|
||||
@ -52,7 +57,11 @@ export class SnapshotClient {
|
||||
|
||||
constructor(private options: SnapshotClientOptions = {}) {}
|
||||
|
||||
async startCurrentRun(filepath: string, name: string, options: SnapshotStateOptions) {
|
||||
async startCurrentRun(
|
||||
filepath: string,
|
||||
name: string,
|
||||
options: SnapshotStateOptions,
|
||||
) {
|
||||
this.filepath = filepath
|
||||
this.name = name
|
||||
|
||||
@ -62,10 +71,7 @@ export class SnapshotClient {
|
||||
if (!this.getSnapshotState(filepath)) {
|
||||
this.snapshotStateMap.set(
|
||||
filepath,
|
||||
await SnapshotState.create(
|
||||
filepath,
|
||||
options,
|
||||
),
|
||||
await SnapshotState.create(filepath, options),
|
||||
)
|
||||
}
|
||||
this.snapshotState = this.getSnapshotState(filepath)
|
||||
@ -99,20 +105,31 @@ export class SnapshotClient {
|
||||
} = options
|
||||
let { received } = options
|
||||
|
||||
if (!filepath)
|
||||
if (!filepath) {
|
||||
throw new Error('Snapshot cannot be used outside of test')
|
||||
}
|
||||
|
||||
if (typeof properties === 'object') {
|
||||
if (typeof received !== 'object' || !received)
|
||||
throw new Error('Received value must be an object when the matcher has properties')
|
||||
if (typeof received !== 'object' || !received) {
|
||||
throw new Error(
|
||||
'Received value must be an object when the matcher has properties',
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const pass = this.options.isEqual?.(received, properties) ?? false
|
||||
// const pass = equals(received, properties, [iterableEquality, subsetEquality])
|
||||
if (!pass)
|
||||
throw createMismatchError('Snapshot properties mismatched', this.snapshotState?.expand, received, properties)
|
||||
else
|
||||
if (!pass) {
|
||||
throw createMismatchError(
|
||||
'Snapshot properties mismatched',
|
||||
this.snapshotState?.expand,
|
||||
received,
|
||||
properties,
|
||||
)
|
||||
}
|
||||
else {
|
||||
received = deepMergeSnapshot(received, properties)
|
||||
}
|
||||
}
|
||||
catch (err: any) {
|
||||
err.message = errorMessage || 'Snapshot mismatched'
|
||||
@ -120,10 +137,7 @@ export class SnapshotClient {
|
||||
}
|
||||
}
|
||||
|
||||
const testName = [
|
||||
name,
|
||||
...(message ? [message] : []),
|
||||
].join(' > ')
|
||||
const testName = [name, ...(message ? [message] : [])].join(' > ')
|
||||
|
||||
const snapshotState = this.getSnapshotState(filepath)
|
||||
|
||||
@ -136,38 +150,49 @@ export class SnapshotClient {
|
||||
rawSnapshot,
|
||||
})
|
||||
|
||||
if (!pass)
|
||||
throw createMismatchError(`Snapshot \`${key || 'unknown'}\` mismatched`, this.snapshotState?.expand, actual?.trim(), expected?.trim())
|
||||
if (!pass) {
|
||||
throw createMismatchError(
|
||||
`Snapshot \`${key || 'unknown'}\` mismatched`,
|
||||
this.snapshotState?.expand,
|
||||
actual?.trim(),
|
||||
expected?.trim(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async assertRaw(options: AssertOptions): Promise<void> {
|
||||
if (!options.rawSnapshot)
|
||||
if (!options.rawSnapshot) {
|
||||
throw new Error('Raw snapshot is required')
|
||||
}
|
||||
|
||||
const {
|
||||
filepath = this.filepath,
|
||||
rawSnapshot,
|
||||
} = options
|
||||
const { filepath = this.filepath, rawSnapshot } = options
|
||||
|
||||
if (rawSnapshot.content == null) {
|
||||
if (!filepath)
|
||||
if (!filepath) {
|
||||
throw new Error('Snapshot cannot be used outside of test')
|
||||
}
|
||||
|
||||
const snapshotState = this.getSnapshotState(filepath)
|
||||
|
||||
// save the filepath, so it don't lose even if the await make it out-of-context
|
||||
options.filepath ||= filepath
|
||||
// resolve and read the raw snapshot file
|
||||
rawSnapshot.file = await snapshotState.environment.resolveRawPath(filepath, rawSnapshot.file)
|
||||
rawSnapshot.content = await snapshotState.environment.readSnapshotFile(rawSnapshot.file) ?? undefined
|
||||
rawSnapshot.file = await snapshotState.environment.resolveRawPath(
|
||||
filepath,
|
||||
rawSnapshot.file,
|
||||
)
|
||||
rawSnapshot.content
|
||||
= (await snapshotState.environment.readSnapshotFile(rawSnapshot.file))
|
||||
?? undefined
|
||||
}
|
||||
|
||||
return this.assert(options)
|
||||
}
|
||||
|
||||
async finishCurrentRun() {
|
||||
if (!this.snapshotState)
|
||||
if (!this.snapshotState) {
|
||||
return null
|
||||
}
|
||||
const result = await this.snapshotState.pack()
|
||||
|
||||
this.snapshotState = undefined
|
||||
|
||||
15
packages/snapshot/src/env/node.ts
vendored
15
packages/snapshot/src/env/node.ts
vendored
@ -14,17 +14,12 @@ export class NodeSnapshotEnvironment implements SnapshotEnvironment {
|
||||
}
|
||||
|
||||
async resolveRawPath(testPath: string, rawPath: string) {
|
||||
return isAbsolute(rawPath)
|
||||
? rawPath
|
||||
: resolve(dirname(testPath), rawPath)
|
||||
return isAbsolute(rawPath) ? rawPath : resolve(dirname(testPath), rawPath)
|
||||
}
|
||||
|
||||
async resolvePath(filepath: string): Promise<string> {
|
||||
return join(
|
||||
join(
|
||||
dirname(filepath),
|
||||
this.options.snapshotsDirName ?? '__snapshots__',
|
||||
),
|
||||
join(dirname(filepath), this.options.snapshotsDirName ?? '__snapshots__'),
|
||||
`${basename(filepath)}.snap`,
|
||||
)
|
||||
}
|
||||
@ -39,13 +34,15 @@ export class NodeSnapshotEnvironment implements SnapshotEnvironment {
|
||||
}
|
||||
|
||||
async readSnapshotFile(filepath: string): Promise<string | null> {
|
||||
if (!existsSync(filepath))
|
||||
if (!existsSync(filepath)) {
|
||||
return null
|
||||
}
|
||||
return fs.readFile(filepath, 'utf-8')
|
||||
}
|
||||
|
||||
async removeSnapshotFile(filepath: string): Promise<void> {
|
||||
if (existsSync(filepath))
|
||||
if (existsSync(filepath)) {
|
||||
await fs.unlink(filepath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +1,17 @@
|
||||
import { basename, dirname, isAbsolute, join, resolve } from 'pathe'
|
||||
import type { SnapshotResult, SnapshotStateOptions, SnapshotSummary } from './types'
|
||||
import type {
|
||||
SnapshotResult,
|
||||
SnapshotStateOptions,
|
||||
SnapshotSummary,
|
||||
} from './types'
|
||||
|
||||
export class SnapshotManager {
|
||||
summary: SnapshotSummary = undefined!
|
||||
extension = '.snap'
|
||||
|
||||
constructor(public options: Omit<SnapshotStateOptions, 'snapshotEnvironment'>) {
|
||||
constructor(
|
||||
public options: Omit<SnapshotStateOptions, 'snapshotEnvironment'>,
|
||||
) {
|
||||
this.clear()
|
||||
}
|
||||
|
||||
@ -18,28 +24,27 @@ export class SnapshotManager {
|
||||
}
|
||||
|
||||
resolvePath(testPath: string) {
|
||||
const resolver = this.options.resolveSnapshotPath || (() => {
|
||||
return join(
|
||||
join(
|
||||
dirname(testPath),
|
||||
'__snapshots__',
|
||||
),
|
||||
`${basename(testPath)}${this.extension}`,
|
||||
)
|
||||
})
|
||||
const resolver
|
||||
= this.options.resolveSnapshotPath
|
||||
|| (() => {
|
||||
return join(
|
||||
join(dirname(testPath), '__snapshots__'),
|
||||
`${basename(testPath)}${this.extension}`,
|
||||
)
|
||||
})
|
||||
|
||||
const path = resolver(testPath, this.extension)
|
||||
return path
|
||||
}
|
||||
|
||||
resolveRawPath(testPath: string, rawPath: string) {
|
||||
return isAbsolute(rawPath)
|
||||
? rawPath
|
||||
: resolve(dirname(testPath), rawPath)
|
||||
return isAbsolute(rawPath) ? rawPath : resolve(dirname(testPath), rawPath)
|
||||
}
|
||||
}
|
||||
|
||||
export function emptySummary(options: Omit<SnapshotStateOptions, 'snapshotEnvironment'>): SnapshotSummary {
|
||||
export function emptySummary(
|
||||
options: Omit<SnapshotStateOptions, 'snapshotEnvironment'>,
|
||||
): SnapshotSummary {
|
||||
const summary = {
|
||||
added: 0,
|
||||
failure: false,
|
||||
@ -59,15 +64,22 @@ export function emptySummary(options: Omit<SnapshotStateOptions, 'snapshotEnviro
|
||||
return summary
|
||||
}
|
||||
|
||||
export function addSnapshotResult(summary: SnapshotSummary, result: SnapshotResult): void {
|
||||
if (result.added)
|
||||
export function addSnapshotResult(
|
||||
summary: SnapshotSummary,
|
||||
result: SnapshotResult,
|
||||
): void {
|
||||
if (result.added) {
|
||||
summary.filesAdded++
|
||||
if (result.fileDeleted)
|
||||
}
|
||||
if (result.fileDeleted) {
|
||||
summary.filesRemoved++
|
||||
if (result.unmatched)
|
||||
}
|
||||
if (result.unmatched) {
|
||||
summary.filesUnmatched++
|
||||
if (result.updated)
|
||||
}
|
||||
if (result.updated) {
|
||||
summary.filesUpdated++
|
||||
}
|
||||
|
||||
summary.added += result.added
|
||||
summary.matched += result.matched
|
||||
@ -81,5 +93,6 @@ export function addSnapshotResult(summary: SnapshotSummary, result: SnapshotResu
|
||||
|
||||
summary.unmatched += result.unmatched
|
||||
summary.updated += result.updated
|
||||
summary.total += result.added + result.matched + result.unmatched + result.updated
|
||||
summary.total
|
||||
+= result.added + result.matched + result.unmatched + result.updated
|
||||
}
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import type MagicString from 'magic-string'
|
||||
import { getCallLastIndex, lineSplitRE, offsetToLineNumber, positionToOffset } from '../../../utils/src/index'
|
||||
import {
|
||||
getCallLastIndex,
|
||||
lineSplitRE,
|
||||
offsetToLineNumber,
|
||||
positionToOffset,
|
||||
} from '../../../utils/src/index'
|
||||
import type { SnapshotEnvironment } from '../types'
|
||||
|
||||
export interface InlineSnapshot {
|
||||
@ -15,35 +20,46 @@ export async function saveInlineSnapshots(
|
||||
) {
|
||||
const MagicString = (await import('magic-string')).default
|
||||
const files = new Set(snapshots.map(i => i.file))
|
||||
await Promise.all(Array.from(files).map(async (file) => {
|
||||
const snaps = snapshots.filter(i => i.file === file)
|
||||
const code = await environment.readSnapshotFile(file) as string
|
||||
const s = new MagicString(code)
|
||||
await Promise.all(
|
||||
Array.from(files).map(async (file) => {
|
||||
const snaps = snapshots.filter(i => i.file === file)
|
||||
const code = (await environment.readSnapshotFile(file)) as string
|
||||
const s = new MagicString(code)
|
||||
|
||||
for (const snap of snaps) {
|
||||
const index = positionToOffset(code, snap.line, snap.column)
|
||||
replaceInlineSnap(code, s, index, snap.snapshot)
|
||||
}
|
||||
for (const snap of snaps) {
|
||||
const index = positionToOffset(code, snap.line, snap.column)
|
||||
replaceInlineSnap(code, s, index, snap.snapshot)
|
||||
}
|
||||
|
||||
const transformed = s.toString()
|
||||
if (transformed !== code)
|
||||
await environment.saveSnapshotFile(file, transformed)
|
||||
}))
|
||||
const transformed = s.toString()
|
||||
if (transformed !== code) {
|
||||
await environment.saveSnapshotFile(file, transformed)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const startObjectRegex = /(?:toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot)\s*\(\s*(?:\/\*[\s\S]*\*\/\s*|\/\/.*(?:[\n\r\u2028\u2029]\s*|[\t\v\f \xA0\u1680\u2000-\u200A\u202F\u205F\u3000\uFEFF]))*\{/
|
||||
const startObjectRegex
|
||||
= /(?:toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot)\s*\(\s*(?:\/\*[\s\S]*\*\/\s*|\/\/.*(?:[\n\r\u2028\u2029]\s*|[\t\v\f \xA0\u1680\u2000-\u200A\u202F\u205F\u3000\uFEFF]))*\{/
|
||||
|
||||
function replaceObjectSnap(code: string, s: MagicString, index: number, newSnap: string) {
|
||||
function replaceObjectSnap(
|
||||
code: string,
|
||||
s: MagicString,
|
||||
index: number,
|
||||
newSnap: string,
|
||||
) {
|
||||
let _code = code.slice(index)
|
||||
const startMatch = startObjectRegex.exec(_code)
|
||||
if (!startMatch)
|
||||
if (!startMatch) {
|
||||
return false
|
||||
}
|
||||
|
||||
_code = _code.slice(startMatch.index)
|
||||
|
||||
let callEnd = getCallLastIndex(_code)
|
||||
if (callEnd === null)
|
||||
if (callEnd === null) {
|
||||
return false
|
||||
}
|
||||
callEnd += index + startMatch.index
|
||||
|
||||
const shapeStart = index + startMatch.index + startMatch[0].length
|
||||
@ -67,10 +83,12 @@ function getObjectShapeEndIndex(code: string, index: number) {
|
||||
let endBraces = 0
|
||||
while (startBraces !== endBraces && index < code.length) {
|
||||
const s = code[index++]
|
||||
if (s === '{')
|
||||
if (s === '{') {
|
||||
startBraces++
|
||||
else if (s === '}')
|
||||
}
|
||||
else if (s === '}') {
|
||||
endBraces++
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
@ -81,28 +99,43 @@ function prepareSnapString(snap: string, source: string, index: number) {
|
||||
const indent = line.match(/^\s*/)![0] || ''
|
||||
const indentNext = indent.includes('\t') ? `${indent}\t` : `${indent} `
|
||||
|
||||
const lines = snap
|
||||
.trim()
|
||||
.replace(/\\/g, '\\\\')
|
||||
.split(/\n/g)
|
||||
const lines = snap.trim().replace(/\\/g, '\\\\').split(/\n/g)
|
||||
|
||||
const isOneline = lines.length <= 1
|
||||
const quote = '`'
|
||||
if (isOneline)
|
||||
return `${quote}${lines.join('\n').replace(/`/g, '\\`').replace(/\$\{/g, '\\${')}${quote}`
|
||||
return `${quote}\n${lines.map(i => i ? indentNext + i : '').join('\n').replace(/`/g, '\\`').replace(/\$\{/g, '\\${')}\n${indent}${quote}`
|
||||
if (isOneline) {
|
||||
return `${quote}${lines
|
||||
.join('\n')
|
||||
.replace(/`/g, '\\`')
|
||||
.replace(/\$\{/g, '\\${')}${quote}`
|
||||
}
|
||||
return `${quote}\n${lines
|
||||
.map(i => (i ? indentNext + i : ''))
|
||||
.join('\n')
|
||||
.replace(/`/g, '\\`')
|
||||
.replace(/\$\{/g, '\\${')}\n${indent}${quote}`
|
||||
}
|
||||
|
||||
const startRegex = /(?:toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot)\s*\(\s*(?:\/\*[\s\S]*\*\/\s*|\/\/.*(?:[\n\r\u2028\u2029]\s*|[\t\v\f \xA0\u1680\u2000-\u200A\u202F\u205F\u3000\uFEFF]))*[\w$]*(['"`)])/
|
||||
export function replaceInlineSnap(code: string, s: MagicString, index: number, newSnap: string) {
|
||||
const startRegex
|
||||
= /(?:toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot)\s*\(\s*(?:\/\*[\s\S]*\*\/\s*|\/\/.*(?:[\n\r\u2028\u2029]\s*|[\t\v\f \xA0\u1680\u2000-\u200A\u202F\u205F\u3000\uFEFF]))*[\w$]*(['"`)])/
|
||||
export function replaceInlineSnap(
|
||||
code: string,
|
||||
s: MagicString,
|
||||
index: number,
|
||||
newSnap: string,
|
||||
) {
|
||||
const codeStartingAtIndex = code.slice(index)
|
||||
|
||||
const startMatch = startRegex.exec(codeStartingAtIndex)
|
||||
|
||||
const firstKeywordMatch = /toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot/.exec(codeStartingAtIndex)
|
||||
const firstKeywordMatch
|
||||
= /toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot/.exec(
|
||||
codeStartingAtIndex,
|
||||
)
|
||||
|
||||
if (!startMatch || startMatch.index !== firstKeywordMatch?.index)
|
||||
if (!startMatch || startMatch.index !== firstKeywordMatch?.index) {
|
||||
return replaceObjectSnap(code, s, index, newSnap)
|
||||
}
|
||||
|
||||
const quote = startMatch[1]
|
||||
const startIndex = index + startMatch.index + startMatch[0].length
|
||||
@ -115,8 +148,9 @@ export function replaceInlineSnap(code: string, s: MagicString, index: number, n
|
||||
|
||||
const quoteEndRE = new RegExp(`(?:^|[^\\\\])${quote}`)
|
||||
const endMatch = quoteEndRE.exec(code.slice(startIndex))
|
||||
if (!endMatch)
|
||||
if (!endMatch) {
|
||||
return false
|
||||
}
|
||||
const endIndex = startIndex + endMatch.index! + endMatch[0].length
|
||||
s.overwrite(startIndex - 1, endIndex, snapString)
|
||||
|
||||
|
||||
@ -24,21 +24,21 @@ export const serialize: NewPlugin['serialize'] = (
|
||||
let callsString = ''
|
||||
if (val.mock.calls.length !== 0) {
|
||||
const indentationNext = indentation + config.indent
|
||||
callsString
|
||||
= ` {${
|
||||
config.spacingOuter
|
||||
}${indentationNext
|
||||
}"calls": ${
|
||||
printer(val.mock.calls, config, indentationNext, depth, refs)
|
||||
}${config.min ? ', ' : ','
|
||||
}${config.spacingOuter
|
||||
}${indentationNext
|
||||
}"results": ${
|
||||
printer(val.mock.results, config, indentationNext, depth, refs)
|
||||
}${config.min ? '' : ','
|
||||
}${config.spacingOuter
|
||||
}${indentation
|
||||
}}`
|
||||
callsString = ` {${config.spacingOuter}${indentationNext}"calls": ${printer(
|
||||
val.mock.calls,
|
||||
config,
|
||||
indentationNext,
|
||||
depth,
|
||||
refs,
|
||||
)}${config.min ? ', ' : ','}${
|
||||
config.spacingOuter
|
||||
}${indentationNext}"results": ${printer(
|
||||
val.mock.results,
|
||||
config,
|
||||
indentationNext,
|
||||
depth,
|
||||
refs,
|
||||
)}${config.min ? '' : ','}${config.spacingOuter}${indentation}}`
|
||||
}
|
||||
|
||||
return `[MockFunction${nameString}]${callsString}`
|
||||
|
||||
@ -9,9 +9,7 @@ import type {
|
||||
Plugin as PrettyFormatPlugin,
|
||||
Plugins as PrettyFormatPlugins,
|
||||
} from 'pretty-format'
|
||||
import {
|
||||
plugins as prettyFormatPlugins,
|
||||
} from 'pretty-format'
|
||||
import { plugins as prettyFormatPlugins } from 'pretty-format'
|
||||
|
||||
import MockSerializer from './mockSerializer'
|
||||
|
||||
|
||||
@ -15,8 +15,11 @@ export async function saveRawSnapshots(
|
||||
environment: SnapshotEnvironment,
|
||||
snapshots: Array<RawSnapshot>,
|
||||
) {
|
||||
await Promise.all(snapshots.map(async (snap) => {
|
||||
if (!snap.readonly)
|
||||
await environment.saveSnapshotFile(snap.file, snap.snapshot)
|
||||
}))
|
||||
await Promise.all(
|
||||
snapshots.map(async (snap) => {
|
||||
if (!snap.readonly) {
|
||||
await environment.saveSnapshotFile(snap.file, snap.snapshot)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@ -8,7 +8,14 @@
|
||||
import type { OptionsReceived as PrettyFormatOptions } from 'pretty-format'
|
||||
import type { ParsedStack } from '../../../utils/src/index'
|
||||
import { parseErrorStacktrace } from '../../../utils/src/source-map'
|
||||
import type { SnapshotData, SnapshotEnvironment, SnapshotMatchOptions, SnapshotResult, SnapshotStateOptions, SnapshotUpdateState } from '../types'
|
||||
import type {
|
||||
SnapshotData,
|
||||
SnapshotEnvironment,
|
||||
SnapshotMatchOptions,
|
||||
SnapshotResult,
|
||||
SnapshotStateOptions,
|
||||
SnapshotUpdateState,
|
||||
} from '../types'
|
||||
import type { InlineSnapshot } from './inlineSnapshot'
|
||||
import { saveInlineSnapshots } from './inlineSnapshot'
|
||||
import type { RawSnapshot, RawSnapshotInfo } from './rawSnapshot'
|
||||
@ -64,10 +71,7 @@ export default class SnapshotState {
|
||||
snapshotContent: string | null,
|
||||
options: SnapshotStateOptions,
|
||||
) {
|
||||
const { data, dirty } = getSnapshotData(
|
||||
snapshotContent,
|
||||
options,
|
||||
)
|
||||
const { data, dirty } = getSnapshotData(snapshotContent, options)
|
||||
this._fileExists = snapshotContent != null // TODO: update on watch?
|
||||
this._initialData = data
|
||||
this._snapshotData = data
|
||||
@ -90,12 +94,13 @@ export default class SnapshotState {
|
||||
this._environment = options.snapshotEnvironment
|
||||
}
|
||||
|
||||
static async create(
|
||||
testFilePath: string,
|
||||
options: SnapshotStateOptions,
|
||||
) {
|
||||
const snapshotPath = await options.snapshotEnvironment.resolvePath(testFilePath)
|
||||
const content = await options.snapshotEnvironment.readSnapshotFile(snapshotPath)
|
||||
static async create(testFilePath: string, options: SnapshotStateOptions) {
|
||||
const snapshotPath = await options.snapshotEnvironment.resolvePath(
|
||||
testFilePath,
|
||||
)
|
||||
const content = await options.snapshotEnvironment.readSnapshotFile(
|
||||
snapshotPath,
|
||||
)
|
||||
return new SnapshotState(testFilePath, snapshotPath, content, options)
|
||||
}
|
||||
|
||||
@ -105,20 +110,26 @@ export default class SnapshotState {
|
||||
|
||||
markSnapshotsAsCheckedForTest(testName: string): void {
|
||||
this._uncheckedKeys.forEach((uncheckedKey) => {
|
||||
if (keyToTestName(uncheckedKey) === testName)
|
||||
if (keyToTestName(uncheckedKey) === testName) {
|
||||
this._uncheckedKeys.delete(uncheckedKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
protected _inferInlineSnapshotStack(stacks: ParsedStack[]) {
|
||||
// if called inside resolves/rejects, stacktrace is different
|
||||
const promiseIndex = stacks.findIndex(i => i.method.match(/__VITEST_(RESOLVES|REJECTS)__/))
|
||||
if (promiseIndex !== -1)
|
||||
const promiseIndex = stacks.findIndex(i =>
|
||||
i.method.match(/__VITEST_(RESOLVES|REJECTS)__/),
|
||||
)
|
||||
if (promiseIndex !== -1) {
|
||||
return stacks[promiseIndex + 3]
|
||||
}
|
||||
|
||||
// inline snapshot function is called __INLINE_SNAPSHOT__
|
||||
// in integrations/snapshot/chai.ts
|
||||
const stackIndex = stacks.findIndex(i => i.method.includes('__INLINE_SNAPSHOT__'))
|
||||
const stackIndex = stacks.findIndex(i =>
|
||||
i.method.includes('__INLINE_SNAPSHOT__'),
|
||||
)
|
||||
return stackIndex !== -1 ? stacks[stackIndex + 2] : null
|
||||
}
|
||||
|
||||
@ -129,11 +140,16 @@ export default class SnapshotState {
|
||||
): void {
|
||||
this._dirty = true
|
||||
if (options.isInline) {
|
||||
const stacks = parseErrorStacktrace(options.error || new Error('snapshot'), { ignoreStackEntries: [] })
|
||||
const stacks = parseErrorStacktrace(
|
||||
options.error || new Error('snapshot'),
|
||||
{ ignoreStackEntries: [] },
|
||||
)
|
||||
const stack = this._inferInlineSnapshotStack(stacks)
|
||||
if (!stack) {
|
||||
throw new Error(
|
||||
`@vitest/snapshot: Couldn't infer stack frame for inline snapshot.\n${JSON.stringify(stacks)}`,
|
||||
`@vitest/snapshot: Couldn't infer stack frame for inline snapshot.\n${JSON.stringify(
|
||||
stacks,
|
||||
)}`,
|
||||
)
|
||||
}
|
||||
// removing 1 column, because source map points to the wrong
|
||||
@ -171,7 +187,8 @@ export default class SnapshotState {
|
||||
const hasExternalSnapshots = Object.keys(this._snapshotData).length
|
||||
const hasInlineSnapshots = this._inlineSnapshots.length
|
||||
const hasRawSnapshots = this._rawSnapshots.length
|
||||
const isEmpty = !hasExternalSnapshots && !hasInlineSnapshots && !hasRawSnapshots
|
||||
const isEmpty
|
||||
= !hasExternalSnapshots && !hasInlineSnapshots && !hasRawSnapshots
|
||||
|
||||
const status: SaveStatus = {
|
||||
deleted: false,
|
||||
@ -180,13 +197,19 @@ export default class SnapshotState {
|
||||
|
||||
if ((this._dirty || this._uncheckedKeys.size) && !isEmpty) {
|
||||
if (hasExternalSnapshots) {
|
||||
await saveSnapshotFile(this._environment, this._snapshotData, this.snapshotPath)
|
||||
await saveSnapshotFile(
|
||||
this._environment,
|
||||
this._snapshotData,
|
||||
this.snapshotPath,
|
||||
)
|
||||
this._fileExists = true
|
||||
}
|
||||
if (hasInlineSnapshots)
|
||||
if (hasInlineSnapshots) {
|
||||
await saveInlineSnapshots(this._environment, this._inlineSnapshots)
|
||||
if (hasRawSnapshots)
|
||||
}
|
||||
if (hasRawSnapshots) {
|
||||
await saveRawSnapshots(this._environment, this._rawSnapshots)
|
||||
}
|
||||
|
||||
status.saved = true
|
||||
}
|
||||
@ -230,26 +253,35 @@ export default class SnapshotState {
|
||||
this._counters.set(testName, (this._counters.get(testName) || 0) + 1)
|
||||
const count = Number(this._counters.get(testName))
|
||||
|
||||
if (!key)
|
||||
if (!key) {
|
||||
key = testNameToKey(testName, count)
|
||||
}
|
||||
|
||||
// Do not mark the snapshot as "checked" if the snapshot is inline and
|
||||
// there's an external snapshot. This way the external snapshot can be
|
||||
// removed with `--updateSnapshot`.
|
||||
if (!(isInline && this._snapshotData[key] !== undefined))
|
||||
if (!(isInline && this._snapshotData[key] !== undefined)) {
|
||||
this._uncheckedKeys.delete(key)
|
||||
}
|
||||
|
||||
let receivedSerialized = (rawSnapshot && typeof received === 'string')
|
||||
? received as string
|
||||
: serialize(received, undefined, this._snapshotFormat)
|
||||
let receivedSerialized
|
||||
= rawSnapshot && typeof received === 'string'
|
||||
? (received as string)
|
||||
: serialize(received, undefined, this._snapshotFormat)
|
||||
|
||||
if (!rawSnapshot)
|
||||
if (!rawSnapshot) {
|
||||
receivedSerialized = addExtraLineBreaks(receivedSerialized)
|
||||
}
|
||||
|
||||
if (rawSnapshot) {
|
||||
// normalize EOL when snapshot contains CRLF but received is LF
|
||||
if (rawSnapshot.content && rawSnapshot.content.match(/\r\n/) && !receivedSerialized.match(/\r\n/))
|
||||
if (
|
||||
rawSnapshot.content
|
||||
&& rawSnapshot.content.match(/\r\n/)
|
||||
&& !receivedSerialized.match(/\r\n/)
|
||||
) {
|
||||
rawSnapshot.content = normalizeNewlines(rawSnapshot.content)
|
||||
}
|
||||
}
|
||||
|
||||
const expected = isInline
|
||||
@ -260,7 +292,10 @@ export default class SnapshotState {
|
||||
const expectedTrimmed = prepareExpected(expected)
|
||||
const pass = expectedTrimmed === prepareExpected(receivedSerialized)
|
||||
const hasSnapshot = expected !== undefined
|
||||
const snapshotIsPersisted = isInline || this._fileExists || (rawSnapshot && rawSnapshot.content != null)
|
||||
const snapshotIsPersisted
|
||||
= isInline
|
||||
|| this._fileExists
|
||||
|| (rawSnapshot && rawSnapshot.content != null)
|
||||
|
||||
if (pass && !isInline && !rawSnapshot) {
|
||||
// Executing a snapshot file as JavaScript and writing the strings back
|
||||
@ -286,19 +321,29 @@ export default class SnapshotState {
|
||||
) {
|
||||
if (this._updateSnapshot === 'all') {
|
||||
if (!pass) {
|
||||
if (hasSnapshot)
|
||||
if (hasSnapshot) {
|
||||
this.updated++
|
||||
else
|
||||
}
|
||||
else {
|
||||
this.added++
|
||||
}
|
||||
|
||||
this._addSnapshot(key, receivedSerialized, { error, isInline, rawSnapshot })
|
||||
this._addSnapshot(key, receivedSerialized, {
|
||||
error,
|
||||
isInline,
|
||||
rawSnapshot,
|
||||
})
|
||||
}
|
||||
else {
|
||||
this.matched++
|
||||
}
|
||||
}
|
||||
else {
|
||||
this._addSnapshot(key, receivedSerialized, { error, isInline, rawSnapshot })
|
||||
this._addSnapshot(key, receivedSerialized, {
|
||||
error,
|
||||
isInline,
|
||||
rawSnapshot,
|
||||
})
|
||||
this.added++
|
||||
}
|
||||
|
||||
@ -317,9 +362,9 @@ export default class SnapshotState {
|
||||
actual: removeExtraLineBreaks(receivedSerialized),
|
||||
count,
|
||||
expected:
|
||||
expectedTrimmed !== undefined
|
||||
? removeExtraLineBreaks(expectedTrimmed)
|
||||
: undefined,
|
||||
expectedTrimmed !== undefined
|
||||
? removeExtraLineBreaks(expectedTrimmed)
|
||||
: undefined,
|
||||
key,
|
||||
pass: false,
|
||||
}
|
||||
@ -350,8 +395,9 @@ export default class SnapshotState {
|
||||
}
|
||||
const uncheckedCount = this.getUncheckedCount()
|
||||
const uncheckedKeys = this.getUncheckedKeys()
|
||||
if (uncheckedCount)
|
||||
if (uncheckedCount) {
|
||||
this.removeUncheckedKeys()
|
||||
}
|
||||
|
||||
const status = await this.save()
|
||||
snapshot.fileDeleted = status.deleted
|
||||
|
||||
@ -7,9 +7,7 @@
|
||||
|
||||
import naturalCompare from 'natural-compare'
|
||||
import type { OptionsReceived as PrettyFormatOptions } from 'pretty-format'
|
||||
import {
|
||||
format as prettyFormat,
|
||||
} from 'pretty-format'
|
||||
import { format as prettyFormat } from 'pretty-format'
|
||||
import { isObject } from '../../../utils/src/index'
|
||||
import type { SnapshotData, SnapshotStateOptions } from '../types'
|
||||
import type { SnapshotEnvironment } from '../types/environment'
|
||||
@ -22,16 +20,20 @@ export function testNameToKey(testName: string, count: number): string {
|
||||
}
|
||||
|
||||
export function keyToTestName(key: string): string {
|
||||
if (!/ \d+$/.test(key))
|
||||
if (!/ \d+$/.test(key)) {
|
||||
throw new Error('Snapshot keys must end with a number.')
|
||||
}
|
||||
|
||||
return key.replace(/ \d+$/, '')
|
||||
}
|
||||
|
||||
export function getSnapshotData(content: string | null, options: SnapshotStateOptions): {
|
||||
data: SnapshotData
|
||||
dirty: boolean
|
||||
} {
|
||||
export function getSnapshotData(
|
||||
content: string | null,
|
||||
options: SnapshotStateOptions,
|
||||
): {
|
||||
data: SnapshotData
|
||||
dirty: boolean
|
||||
} {
|
||||
const update = options.updateSnapshot
|
||||
const data = Object.create(null)
|
||||
let snapshotContents = ''
|
||||
@ -53,8 +55,9 @@ export function getSnapshotData(content: string | null, options: SnapshotStateOp
|
||||
// if (update === 'none' && isInvalid)
|
||||
// throw validationResult
|
||||
|
||||
if ((update === 'all' || update === 'new') && isInvalid)
|
||||
if ((update === 'all' || update === 'new') && isInvalid) {
|
||||
dirty = true
|
||||
}
|
||||
|
||||
return { data, dirty }
|
||||
}
|
||||
@ -69,7 +72,7 @@ export function addExtraLineBreaks(string: string): string {
|
||||
// Instead of trim, which can remove additional newlines or spaces
|
||||
// at beginning or end of the content from a custom serializer.
|
||||
export function removeExtraLineBreaks(string: string): string {
|
||||
return (string.length > 2 && string.startsWith('\n') && string.endsWith('\n'))
|
||||
return string.length > 2 && string.startsWith('\n') && string.endsWith('\n')
|
||||
? string.slice(1, -1)
|
||||
: string
|
||||
}
|
||||
@ -90,7 +93,11 @@ export function removeExtraLineBreaks(string: string): string {
|
||||
const escapeRegex = true
|
||||
const printFunctionName = false
|
||||
|
||||
export function serialize(val: unknown, indent = 2, formatOverrides: PrettyFormatOptions = {}): string {
|
||||
export function serialize(
|
||||
val: unknown,
|
||||
indent = 2,
|
||||
formatOverrides: PrettyFormatOptions = {},
|
||||
): string {
|
||||
return normalizeNewlines(
|
||||
prettyFormat(val, {
|
||||
escapeRegex,
|
||||
@ -136,20 +143,21 @@ export async function saveSnapshotFile(
|
||||
const snapshots = Object.keys(snapshotData)
|
||||
.sort(naturalCompare)
|
||||
.map(
|
||||
key => `exports[${printBacktickString(key)}] = ${printBacktickString(normalizeNewlines(snapshotData[key]))};`,
|
||||
key =>
|
||||
`exports[${printBacktickString(key)}] = ${printBacktickString(
|
||||
normalizeNewlines(snapshotData[key]),
|
||||
)};`,
|
||||
)
|
||||
|
||||
const content = `${environment.getHeader()}\n\n${snapshots.join('\n\n')}\n`
|
||||
const oldContent = await environment.readSnapshotFile(snapshotPath)
|
||||
const skipWriting = oldContent != null && oldContent === content
|
||||
|
||||
if (skipWriting)
|
||||
if (skipWriting) {
|
||||
return
|
||||
}
|
||||
|
||||
await environment.saveSnapshotFile(
|
||||
snapshotPath,
|
||||
content,
|
||||
)
|
||||
await environment.saveSnapshotFile(snapshotPath, content)
|
||||
}
|
||||
|
||||
export async function saveSnapshotFileRaw(
|
||||
@ -160,13 +168,11 @@ export async function saveSnapshotFileRaw(
|
||||
const oldContent = await environment.readSnapshotFile(snapshotPath)
|
||||
const skipWriting = oldContent != null && oldContent === content
|
||||
|
||||
if (skipWriting)
|
||||
if (skipWriting) {
|
||||
return
|
||||
}
|
||||
|
||||
await environment.saveSnapshotFile(
|
||||
snapshotPath,
|
||||
content,
|
||||
)
|
||||
await environment.saveSnapshotFile(snapshotPath, content)
|
||||
}
|
||||
|
||||
export function prepareExpected(expected?: string) {
|
||||
@ -176,8 +182,9 @@ export function prepareExpected(expected?: string) {
|
||||
const matchObject = /^( +)\}\s+$/m.exec(expected || '')
|
||||
const objectIndent = matchObject?.[1]?.length
|
||||
|
||||
if (objectIndent)
|
||||
if (objectIndent) {
|
||||
return objectIndent
|
||||
}
|
||||
|
||||
// Attempts to find indentation for texts.
|
||||
// Matches the quote of first line.
|
||||
@ -191,7 +198,8 @@ export function prepareExpected(expected?: string) {
|
||||
|
||||
if (startIndent) {
|
||||
expectedTrimmed = expectedTrimmed
|
||||
?.replace(new RegExp(`^${' '.repeat(startIndent)}`, 'gm'), '').replace(/ +\}$/, '}')
|
||||
?.replace(new RegExp(`^${' '.repeat(startIndent)}`, 'gm'), '')
|
||||
.replace(/ +\}$/, '}')
|
||||
}
|
||||
|
||||
return expectedTrimmed
|
||||
@ -235,9 +243,12 @@ export function deepMergeSnapshot(target: any, source: any): any {
|
||||
const mergedOutput = { ...target }
|
||||
Object.keys(source).forEach((key) => {
|
||||
if (isObject(source[key]) && !source[key].$$typeof) {
|
||||
if (!(key in target))
|
||||
if (!(key in target)) {
|
||||
Object.assign(mergedOutput, { [key]: source[key] })
|
||||
else mergedOutput[key] = deepMergeSnapshot(target[key], source[key])
|
||||
}
|
||||
else {
|
||||
mergedOutput[key] = deepMergeSnapshot(target[key], source[key])
|
||||
}
|
||||
}
|
||||
else if (Array.isArray(source[key])) {
|
||||
mergedOutput[key] = deepMergeArray(target[key], source[key])
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
import type { OptionsReceived as PrettyFormatOptions, Plugin as PrettyFormatPlugin } from 'pretty-format'
|
||||
import type {
|
||||
OptionsReceived as PrettyFormatOptions,
|
||||
Plugin as PrettyFormatPlugin,
|
||||
} from 'pretty-format'
|
||||
import type { RawSnapshotInfo } from '../port/rawSnapshot'
|
||||
import type { SnapshotEnvironment, SnapshotEnvironmentOptions } from './environment'
|
||||
import type {
|
||||
SnapshotEnvironment,
|
||||
SnapshotEnvironmentOptions,
|
||||
} from './environment'
|
||||
|
||||
export type { SnapshotEnvironment, SnapshotEnvironmentOptions }
|
||||
export type SnapshotData = Record<string, string>
|
||||
|
||||
@ -39,15 +39,14 @@ export default defineConfig([
|
||||
format: 'esm',
|
||||
},
|
||||
external,
|
||||
plugins: [
|
||||
dts({ respectExternal: true }),
|
||||
],
|
||||
plugins: [dts({ respectExternal: true })],
|
||||
onwarn,
|
||||
},
|
||||
])
|
||||
|
||||
function onwarn(message) {
|
||||
if (['EMPTY_BUNDLE', 'CIRCULAR_DEPENDENCY'].includes(message.code))
|
||||
if (['EMPTY_BUNDLE', 'CIRCULAR_DEPENDENCY'].includes(message.code)) {
|
||||
return
|
||||
}
|
||||
console.error(message)
|
||||
}
|
||||
|
||||
@ -30,8 +30,13 @@ interface MockSettledResultRejected {
|
||||
value: any
|
||||
}
|
||||
|
||||
export type MockResult<T> = MockResultReturn<T> | MockResultThrow | MockResultIncomplete
|
||||
export type MockSettledResult<T> = MockSettledResultFulfilled<T> | MockSettledResultRejected
|
||||
export type MockResult<T> =
|
||||
| MockResultReturn<T>
|
||||
| MockResultThrow
|
||||
| MockResultIncomplete
|
||||
export type MockSettledResult<T> =
|
||||
| MockSettledResultFulfilled<T>
|
||||
| MockSettledResultRejected
|
||||
|
||||
export interface MockContext<TArgs, TReturns> {
|
||||
/**
|
||||
@ -137,16 +142,19 @@ type Methods<T> = keyof {
|
||||
[K in keyof T as T[K] extends Procedure ? K : never]: T[K];
|
||||
}
|
||||
type Properties<T> = {
|
||||
[K in keyof T]: T[K] extends Procedure ? never : K
|
||||
}[keyof T] & (string | symbol)
|
||||
[K in keyof T]: T[K] extends Procedure ? never : K;
|
||||
}[keyof T] &
|
||||
(string | symbol)
|
||||
type Classes<T> = {
|
||||
[K in keyof T]: T[K] extends new (...args: any[]) => any ? K : never
|
||||
}[keyof T] & (string | symbol)
|
||||
[K in keyof T]: T[K] extends new (...args: any[]) => any ? K : never;
|
||||
}[keyof T] &
|
||||
(string | symbol)
|
||||
|
||||
/**
|
||||
* @deprecated Use MockInstance<A, R> instead
|
||||
*/
|
||||
export interface SpyInstance<TArgs extends any[] = any[], TReturns = any> extends MockInstance<TArgs, TReturns> {}
|
||||
export interface SpyInstance<TArgs extends any[] = any[], TReturns = any>
|
||||
extends MockInstance<TArgs, TReturns> {}
|
||||
|
||||
export interface MockInstance<TArgs extends any[] = any[], TReturns = any> {
|
||||
/**
|
||||
@ -193,7 +201,7 @@ export interface MockInstance<TArgs extends any[] = any[], TReturns = any> {
|
||||
* const increment = vi.fn().mockImplementation(count => count + 1);
|
||||
* expect(increment(3)).toBe(4);
|
||||
*/
|
||||
mockImplementation: (fn: ((...args: TArgs) => TReturns)) => this
|
||||
mockImplementation: (fn: (...args: TArgs) => TReturns) => this
|
||||
/**
|
||||
* Accepts a function that will be used as a mock implementation during the next call. Can be chained so that multiple function calls produce different results.
|
||||
* @example
|
||||
@ -201,7 +209,7 @@ export interface MockInstance<TArgs extends any[] = any[], TReturns = any> {
|
||||
* expect(fn(3)).toBe(4);
|
||||
* expect(fn(3)).toBe(3);
|
||||
*/
|
||||
mockImplementationOnce: (fn: ((...args: TArgs) => TReturns)) => this
|
||||
mockImplementationOnce: (fn: (...args: TArgs) => TReturns) => this
|
||||
/**
|
||||
* Overrides the original mock implementation temporarily while the callback is being executed.
|
||||
* @example
|
||||
@ -213,7 +221,10 @@ export interface MockInstance<TArgs extends any[] = any[], TReturns = any> {
|
||||
*
|
||||
* myMockFn() // 'original'
|
||||
*/
|
||||
withImplementation: <T>(fn: ((...args: TArgs) => TReturns), cb: () => T) => T extends Promise<unknown> ? Promise<this> : this
|
||||
withImplementation: <T>(
|
||||
fn: (...args: TArgs) => TReturns,
|
||||
cb: () => T
|
||||
) => T extends Promise<unknown> ? Promise<this> : this
|
||||
/**
|
||||
* Use this if you need to return `this` context from the method without invoking actual implementation.
|
||||
*/
|
||||
@ -278,11 +289,18 @@ export interface MockInstance<TArgs extends any[] = any[], TReturns = any> {
|
||||
mockRejectedValueOnce: (obj: any) => this
|
||||
}
|
||||
|
||||
export interface Mock<TArgs extends any[] = any, TReturns = any> extends MockInstance<TArgs, TReturns> {
|
||||
export interface Mock<TArgs extends any[] = any, TReturns = any>
|
||||
extends MockInstance<TArgs, TReturns> {
|
||||
new (...args: TArgs): TReturns
|
||||
(...args: TArgs): TReturns
|
||||
}
|
||||
export interface PartialMock<TArgs extends any[] = any, TReturns = any> extends MockInstance<TArgs, TReturns extends Promise<Awaited<TReturns>> ? Promise<Partial<Awaited<TReturns>>> : Partial<TReturns>> {
|
||||
export interface PartialMock<TArgs extends any[] = any, TReturns = any>
|
||||
extends MockInstance<
|
||||
TArgs,
|
||||
TReturns extends Promise<Awaited<TReturns>>
|
||||
? Promise<Partial<Awaited<TReturns>>>
|
||||
: Partial<TReturns>
|
||||
> {
|
||||
new (...args: TArgs): TReturns
|
||||
(...args: TArgs): TReturns
|
||||
}
|
||||
@ -292,23 +310,33 @@ export type MaybeMockedConstructor<T> = T extends new (
|
||||
) => infer R
|
||||
? Mock<ConstructorParameters<T>, R>
|
||||
: T
|
||||
export type MockedFunction<T extends Procedure> = Mock<Parameters<T>, ReturnType<T>> & {
|
||||
export type MockedFunction<T extends Procedure> = Mock<
|
||||
Parameters<T>,
|
||||
ReturnType<T>
|
||||
> & {
|
||||
[K in keyof T]: T[K];
|
||||
}
|
||||
export type PartiallyMockedFunction<T extends Procedure> = PartialMock<Parameters<T>, ReturnType<T>> & {
|
||||
export type PartiallyMockedFunction<T extends Procedure> = PartialMock<
|
||||
Parameters<T>,
|
||||
ReturnType<T>
|
||||
> & {
|
||||
[K in keyof T]: T[K];
|
||||
}
|
||||
export type MockedFunctionDeep<T extends Procedure> = Mock<Parameters<T>, ReturnType<T>> & MockedObjectDeep<T>
|
||||
export type PartiallyMockedFunctionDeep<T extends Procedure> = PartialMock<Parameters<T>, ReturnType<T>> & MockedObjectDeep<T>
|
||||
export type MockedFunctionDeep<T extends Procedure> = Mock<
|
||||
Parameters<T>,
|
||||
ReturnType<T>
|
||||
> &
|
||||
MockedObjectDeep<T>
|
||||
export type PartiallyMockedFunctionDeep<T extends Procedure> = PartialMock<
|
||||
Parameters<T>,
|
||||
ReturnType<T>
|
||||
> &
|
||||
MockedObjectDeep<T>
|
||||
export type MockedObject<T> = MaybeMockedConstructor<T> & {
|
||||
[K in Methods<T>]: T[K] extends Procedure
|
||||
? MockedFunction<T[K]>
|
||||
: T[K];
|
||||
[K in Methods<T>]: T[K] extends Procedure ? MockedFunction<T[K]> : T[K];
|
||||
} & { [K in Properties<T>]: T[K] }
|
||||
export type MockedObjectDeep<T> = MaybeMockedConstructor<T> & {
|
||||
[K in Methods<T>]: T[K] extends Procedure
|
||||
? MockedFunctionDeep<T[K]>
|
||||
: T[K];
|
||||
[K in Methods<T>]: T[K] extends Procedure ? MockedFunctionDeep<T[K]> : T[K];
|
||||
} & { [K in Properties<T>]: MaybeMockedDeep<T[K]> }
|
||||
|
||||
export type MaybeMockedDeep<T> = T extends Procedure
|
||||
@ -340,8 +368,8 @@ interface Constructable {
|
||||
}
|
||||
|
||||
export type MockedClass<T extends Constructable> = MockInstance<
|
||||
T extends new (...args: infer P) => any ? P : never,
|
||||
InstanceType<T>
|
||||
T extends new (...args: infer P) => any ? P : never,
|
||||
InstanceType<T>
|
||||
> & {
|
||||
prototype: T extends { prototype: any } ? Mocked<T['prototype']> : never
|
||||
} & T
|
||||
@ -351,32 +379,35 @@ export type Mocked<T> = {
|
||||
? MockInstance<Args, Returns>
|
||||
: T[P] extends Constructable
|
||||
? MockedClass<T[P]>
|
||||
: T[P]
|
||||
} &
|
||||
T
|
||||
: T[P];
|
||||
} & T
|
||||
|
||||
export const mocks = new Set<MockInstance>()
|
||||
|
||||
export function isMockFunction(fn: any): fn is MockInstance {
|
||||
return typeof fn === 'function'
|
||||
&& '_isMockFunction' in fn
|
||||
&& fn._isMockFunction
|
||||
return (
|
||||
typeof fn === 'function' && '_isMockFunction' in fn && fn._isMockFunction
|
||||
)
|
||||
}
|
||||
|
||||
export function spyOn<T, S extends Properties<Required<T>>>(
|
||||
obj: T,
|
||||
methodName: S,
|
||||
accessType: 'get',
|
||||
accessType: 'get'
|
||||
): MockInstance<[], T[S]>
|
||||
export function spyOn<T, G extends Properties<Required<T>>>(
|
||||
obj: T,
|
||||
methodName: G,
|
||||
accessType: 'set',
|
||||
accessType: 'set'
|
||||
): MockInstance<[T[G]], void>
|
||||
export function spyOn<T, M extends (Classes<Required<T>> | Methods<Required<T>>)>(
|
||||
export function spyOn<T, M extends Classes<Required<T>> | Methods<Required<T>>>(
|
||||
obj: T,
|
||||
methodName: M,
|
||||
): Required<T>[M] extends ({ new (...args: infer A): infer R }) | ((...args: infer A) => infer R) ? MockInstance<A, R> : never
|
||||
methodName: M
|
||||
): Required<T>[M] extends
|
||||
| { new (...args: infer A): infer R }
|
||||
| ((...args: infer A) => infer R)
|
||||
? MockInstance<A, R>
|
||||
: never
|
||||
export function spyOn<T, K extends keyof T>(
|
||||
obj: T,
|
||||
method: K,
|
||||
@ -419,13 +450,15 @@ function enhanceSpy<TArgs extends any[], TReturns>(
|
||||
},
|
||||
get results() {
|
||||
return state.results.map(([callType, value]) => {
|
||||
const type = callType === 'error' ? 'throw' as const : 'return' as const
|
||||
const type
|
||||
= callType === 'error' ? ('throw' as const) : ('return' as const)
|
||||
return { type, value }
|
||||
})
|
||||
},
|
||||
get settledResults() {
|
||||
return state.resolves.map(([callType, value]) => {
|
||||
const type = callType === 'error' ? 'rejected' as const : 'fulfilled' as const
|
||||
const type
|
||||
= callType === 'error' ? ('rejected' as const) : ('fulfilled' as const)
|
||||
return { type, value }
|
||||
})
|
||||
},
|
||||
@ -440,7 +473,12 @@ function enhanceSpy<TArgs extends any[], TReturns>(
|
||||
function mockCall(this: unknown, ...args: any) {
|
||||
instances.push(this)
|
||||
invocations.push(++callOrder)
|
||||
const impl = implementationChangedTemporarily ? implementation! : (onceImplementations.shift() || implementation || state.getOriginal() || (() => {}))
|
||||
const impl = implementationChangedTemporarily
|
||||
? implementation!
|
||||
: onceImplementations.shift()
|
||||
|| implementation
|
||||
|| state.getOriginal()
|
||||
|| (() => {})
|
||||
return impl.apply(this, args)
|
||||
}
|
||||
|
||||
@ -485,9 +523,18 @@ function enhanceSpy<TArgs extends any[], TReturns>(
|
||||
return stub
|
||||
}
|
||||
|
||||
function withImplementation(fn: (...args: TArgs) => TReturns, cb: () => void): MockInstance<TArgs, TReturns>
|
||||
function withImplementation(fn: (...args: TArgs) => TReturns, cb: () => Promise<void>): Promise<MockInstance<TArgs, TReturns>>
|
||||
function withImplementation(fn: (...args: TArgs) => TReturns, cb: () => void | Promise<void>): MockInstance<TArgs, TReturns> | Promise<MockInstance<TArgs, TReturns>> {
|
||||
function withImplementation(
|
||||
fn: (...args: TArgs) => TReturns,
|
||||
cb: () => void
|
||||
): MockInstance<TArgs, TReturns>
|
||||
function withImplementation(
|
||||
fn: (...args: TArgs) => TReturns,
|
||||
cb: () => Promise<void>
|
||||
): Promise<MockInstance<TArgs, TReturns>>
|
||||
function withImplementation(
|
||||
fn: (...args: TArgs) => TReturns,
|
||||
cb: () => void | Promise<void>,
|
||||
): MockInstance<TArgs, TReturns> | Promise<MockInstance<TArgs, TReturns>> {
|
||||
const originalImplementation = implementation
|
||||
|
||||
implementation = fn
|
||||
@ -521,7 +568,8 @@ function enhanceSpy<TArgs extends any[], TReturns>(
|
||||
})
|
||||
|
||||
stub.mockReturnValue = (val: TReturns) => stub.mockImplementation(() => val)
|
||||
stub.mockReturnValueOnce = (val: TReturns) => stub.mockImplementationOnce(() => val)
|
||||
stub.mockReturnValueOnce = (val: TReturns) =>
|
||||
stub.mockImplementationOnce(() => val)
|
||||
|
||||
stub.mockResolvedValue = (val: Awaited<TReturns>) =>
|
||||
stub.mockImplementation(() => Promise.resolve(val as TReturns) as any)
|
||||
@ -553,9 +601,12 @@ export function fn<TArgs extends any[] = any[], R = any>(
|
||||
export function fn<TArgs extends any[] = any[], R = any>(
|
||||
implementation?: (...args: TArgs) => R,
|
||||
): Mock<TArgs, R> {
|
||||
const enhancedSpy = enhanceSpy(tinyspy.internalSpyOn({ spy: implementation || (() => {}) }, 'spy'))
|
||||
if (implementation)
|
||||
const enhancedSpy = enhanceSpy(
|
||||
tinyspy.internalSpyOn({ spy: implementation || (() => {}) }, 'spy'),
|
||||
)
|
||||
if (implementation) {
|
||||
enhancedSpy.mockImplementation(implementation)
|
||||
}
|
||||
|
||||
return enhancedSpy as Mock
|
||||
}
|
||||
|
||||
12
packages/ui/client/auto-imports.d.ts
vendored
12
packages/ui/client/auto-imports.d.ts
vendored
@ -99,7 +99,7 @@ declare global {
|
||||
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
|
||||
const provide: typeof import('vue')['provide']
|
||||
const provideLocal: typeof import('@vueuse/core')['provideLocal']
|
||||
const provideResizing: typeof import('./composables/browser')['provideResizing']
|
||||
const provideResizing: typeof import("./composables/browser")["provideResizing"]
|
||||
const reactify: typeof import('@vueuse/core')['reactify']
|
||||
const reactifyObject: typeof import('@vueuse/core')['reactifyObject']
|
||||
const reactive: typeof import('vue')['reactive']
|
||||
@ -107,14 +107,14 @@ declare global {
|
||||
const reactiveOmit: typeof import('@vueuse/core')['reactiveOmit']
|
||||
const reactivePick: typeof import('@vueuse/core')['reactivePick']
|
||||
const readonly: typeof import('vue')['readonly']
|
||||
const recalculateDetailPanels: typeof import('./composables/browser')['recalculateDetailPanels']
|
||||
const recalculateDetailPanels: typeof import("./composables/browser")["recalculateDetailPanels"]
|
||||
const ref: typeof import('vue')['ref']
|
||||
const refAutoReset: typeof import('@vueuse/core')['refAutoReset']
|
||||
const refDebounced: typeof import('@vueuse/core')['refDebounced']
|
||||
const refDefault: typeof import('@vueuse/core')['refDefault']
|
||||
const refThrottled: typeof import('@vueuse/core')['refThrottled']
|
||||
const refWithControl: typeof import('@vueuse/core')['refWithControl']
|
||||
const registerResizingListener: typeof import('./composables/browser')['registerResizingListener']
|
||||
const registerResizingListener: typeof import("./composables/browser")["registerResizingListener"]
|
||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||
const resolveRef: typeof import('@vueuse/core')['resolveRef']
|
||||
const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
|
||||
@ -150,7 +150,7 @@ declare global {
|
||||
const tryOnMounted: typeof import('@vueuse/core')['tryOnMounted']
|
||||
const tryOnScopeDispose: typeof import('@vueuse/core')['tryOnScopeDispose']
|
||||
const tryOnUnmounted: typeof import('@vueuse/core')['tryOnUnmounted']
|
||||
const unifiedDiff: typeof import('./composables/diff')['unifiedDiff']
|
||||
const unifiedDiff: typeof import("./composables/diff")["unifiedDiff"]
|
||||
const unref: typeof import('vue')['unref']
|
||||
const unrefElement: typeof import('@vueuse/core')['unrefElement']
|
||||
const until: typeof import('@vueuse/core')['until']
|
||||
@ -245,7 +245,7 @@ declare global {
|
||||
const useMutationObserver: typeof import('@vueuse/core')['useMutationObserver']
|
||||
const useNavigatorLanguage: typeof import('@vueuse/core')['useNavigatorLanguage']
|
||||
const useNetwork: typeof import('@vueuse/core')['useNetwork']
|
||||
const useNotifyResizing: typeof import('./composables/browser')['useNotifyResizing']
|
||||
const useNotifyResizing: typeof import("./composables/browser")["useNotifyResizing"]
|
||||
const useNow: typeof import('@vueuse/core')['useNow']
|
||||
const useObjectUrl: typeof import('@vueuse/core')['useObjectUrl']
|
||||
const useOffsetPagination: typeof import('@vueuse/core')['useOffsetPagination']
|
||||
@ -267,7 +267,7 @@ declare global {
|
||||
const useRafFn: typeof import('@vueuse/core')['useRafFn']
|
||||
const useRefHistory: typeof import('@vueuse/core')['useRefHistory']
|
||||
const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver']
|
||||
const useResizing: typeof import('./composables/browser')['useResizing']
|
||||
const useResizing: typeof import("./composables/browser")["useResizing"]
|
||||
const useRoute: typeof import('vue-router')['useRoute']
|
||||
const useRouter: typeof import('vue-router')['useRouter']
|
||||
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
|
||||
|
||||
@ -1,65 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { viewport, customViewport } from '~/composables/browser'
|
||||
import type { ViewportSize } from '~/composables/browser'
|
||||
import { setIframeViewport, getCurrentBrowserIframe } from '~/composables/api'
|
||||
import { viewport, customViewport } from "~/composables/browser";
|
||||
import type { ViewportSize } from "~/composables/browser";
|
||||
import { setIframeViewport, getCurrentBrowserIframe } from "~/composables/api";
|
||||
|
||||
const sizes: Record<ViewportSize, [width: string, height: string] | null> = {
|
||||
'small-mobile': ['320px', '568px'],
|
||||
'large-mobile': ['414px', '896px'],
|
||||
tablet: ['834px', '1112px'],
|
||||
full: ['100%', '100%'],
|
||||
"small-mobile": ["320px", "568px"],
|
||||
"large-mobile": ["414px", "896px"],
|
||||
tablet: ["834px", "1112px"],
|
||||
full: ["100%", "100%"],
|
||||
// should not be used manually, this is just
|
||||
// a fallback for the case when the viewport is not set correctly
|
||||
custom: null,
|
||||
}
|
||||
};
|
||||
|
||||
async function changeViewport(name: ViewportSize) {
|
||||
if (viewport.value === name) {
|
||||
viewport.value = customViewport.value ? 'custom' : 'full'
|
||||
viewport.value = customViewport.value ? "custom" : "full";
|
||||
} else {
|
||||
viewport.value = name
|
||||
viewport.value = name;
|
||||
}
|
||||
|
||||
const iframe = getCurrentBrowserIframe()
|
||||
const iframe = getCurrentBrowserIframe();
|
||||
if (!iframe) {
|
||||
console.warn('Iframe not found')
|
||||
return
|
||||
console.warn("Iframe not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const [width, height] = sizes[viewport.value] || customViewport.value || sizes.full
|
||||
const [width, height] =
|
||||
sizes[viewport.value] || customViewport.value || sizes.full;
|
||||
|
||||
await setIframeViewport(width, height)
|
||||
await setIframeViewport(width, height);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div h="full" flex="~ col">
|
||||
<div
|
||||
p="3"
|
||||
h-10
|
||||
flex="~ gap-2"
|
||||
items-center
|
||||
bg-header
|
||||
border="b base"
|
||||
>
|
||||
<div p="3" h-10 flex="~ gap-2" items-center bg-header border="b base">
|
||||
<div class="i-carbon-content-delivery-network" />
|
||||
<span
|
||||
pl-1
|
||||
font-bold
|
||||
text-sm
|
||||
flex-auto
|
||||
ws-nowrap
|
||||
overflow-hidden
|
||||
truncate
|
||||
>Browser UI</span>
|
||||
<span pl-1 font-bold text-sm flex-auto ws-nowrap overflow-hidden truncate
|
||||
>Browser UI</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
p="l3 y2 r2"
|
||||
flex="~ gap-2"
|
||||
items-center
|
||||
bg-header
|
||||
border="b-2 base"
|
||||
>
|
||||
<div p="l3 y2 r2" flex="~ gap-2" items-center bg-header border="b-2 base">
|
||||
<!-- TODO: these are only for preview (thank you Storybook!), we need to support more different and custom sizes (as a dropdown) -->
|
||||
<IconButton
|
||||
v-tooltip.bottom="'Flexible'"
|
||||
@ -91,7 +73,11 @@ async function changeViewport(name: ViewportSize) {
|
||||
/>
|
||||
</div>
|
||||
<div flex-auto class="scrolls">
|
||||
<div id="tester-ui" class="flex h-full justify-center items-center font-light op70" style="overflow: auto; width: 100%; height: 100%">
|
||||
<div
|
||||
id="tester-ui"
|
||||
class="flex h-full justify-center items-center font-light op70"
|
||||
style="overflow: auto; width: 100%; height: 100%"
|
||||
>
|
||||
Select a test to run
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,66 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import type CodeMirror from 'codemirror'
|
||||
import type CodeMirror from "codemirror";
|
||||
|
||||
const { mode, readOnly } = defineProps<{
|
||||
mode?: string
|
||||
readOnly?: boolean
|
||||
}>()
|
||||
mode?: string;
|
||||
readOnly?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'save', content: string): void
|
||||
}>()
|
||||
(event: "save", content: string): void;
|
||||
}>();
|
||||
|
||||
const modelValue = defineModel<string>()
|
||||
const modelValue = defineModel<string>();
|
||||
|
||||
const attrs = useAttrs()
|
||||
const attrs = useAttrs();
|
||||
|
||||
const modeMap: Record<string, any> = {
|
||||
// html: 'htmlmixed',
|
||||
// vue: 'htmlmixed',
|
||||
// svelte: 'htmlmixed',
|
||||
js: 'javascript',
|
||||
mjs: 'javascript',
|
||||
cjs: 'javascript',
|
||||
ts: { name: 'javascript', typescript: true },
|
||||
mts: { name: 'javascript', typescript: true },
|
||||
cts: { name: 'javascript', typescript: true },
|
||||
jsx: { name: 'javascript', jsx: true },
|
||||
tsx: { name: 'javascript', typescript: true, jsx: true },
|
||||
}
|
||||
js: "javascript",
|
||||
mjs: "javascript",
|
||||
cjs: "javascript",
|
||||
ts: { name: "javascript", typescript: true },
|
||||
mts: { name: "javascript", typescript: true },
|
||||
cts: { name: "javascript", typescript: true },
|
||||
jsx: { name: "javascript", jsx: true },
|
||||
tsx: { name: "javascript", typescript: true, jsx: true },
|
||||
};
|
||||
|
||||
const el = ref<HTMLTextAreaElement>()
|
||||
const el = ref<HTMLTextAreaElement>();
|
||||
|
||||
const cm = shallowRef<CodeMirror.EditorFromTextArea>()
|
||||
const cm = shallowRef<CodeMirror.EditorFromTextArea>();
|
||||
|
||||
defineExpose({ cm })
|
||||
defineExpose({ cm });
|
||||
|
||||
onMounted(async () => {
|
||||
cm.value = useCodeMirror(el, modelValue as unknown as Ref<string>, {
|
||||
...attrs,
|
||||
mode: modeMap[mode || ''] || mode,
|
||||
mode: modeMap[mode || ""] || mode,
|
||||
readOnly: readOnly ? true : undefined,
|
||||
extraKeys: {
|
||||
'Cmd-S': function (cm) {
|
||||
emit('save', cm.getValue())
|
||||
"Cmd-S": function (cm) {
|
||||
emit("save", cm.getValue());
|
||||
},
|
||||
'Ctrl-S': function (cm) {
|
||||
emit('save', cm.getValue())
|
||||
"Ctrl-S": function (cm) {
|
||||
emit("save", cm.getValue());
|
||||
},
|
||||
},
|
||||
})
|
||||
cm.value.setSize('100%', '100%')
|
||||
cm.value.clearHistory()
|
||||
setTimeout(() => cm.value!.refresh(), 100)
|
||||
})
|
||||
});
|
||||
cm.value.setSize("100%", "100%");
|
||||
cm.value.clearHistory();
|
||||
setTimeout(() => cm.value!.refresh(), 100);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
relative
|
||||
font-mono
|
||||
text-sm
|
||||
class="codemirror-scrolls"
|
||||
>
|
||||
<div relative font-mono text-sm class="codemirror-scrolls">
|
||||
<textarea ref="el" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -1,11 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { client, isConnected, isConnecting, browserState } from '~/composables/client'
|
||||
import {
|
||||
client,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
browserState,
|
||||
} from "~/composables/client";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="!isConnected">
|
||||
<div
|
||||
fixed inset-0 p2 z-10
|
||||
fixed
|
||||
inset-0
|
||||
p2
|
||||
z-10
|
||||
select-none
|
||||
text="center sm"
|
||||
bg="overlay"
|
||||
@ -16,18 +24,27 @@ import { client, isConnected, isConnecting, browserState } from '~/composables/c
|
||||
<div
|
||||
h-full
|
||||
flex="~ col gap-2"
|
||||
items-center justify-center
|
||||
items-center
|
||||
justify-center
|
||||
:class="isConnecting ? 'animate-pulse' : ''"
|
||||
>
|
||||
<div
|
||||
text="5xl"
|
||||
:class="isConnecting ? 'i-carbon:renew animate-spin animate-reverse' : 'i-carbon-wifi-off'"
|
||||
:class="
|
||||
isConnecting
|
||||
? 'i-carbon:renew animate-spin animate-reverse'
|
||||
: 'i-carbon-wifi-off'
|
||||
"
|
||||
/>
|
||||
<div text-2xl>
|
||||
{{ isConnecting ? 'Connecting...' : 'Disconnected' }}
|
||||
{{ isConnecting ? "Connecting..." : "Disconnected" }}
|
||||
</div>
|
||||
<div text-lg op50>
|
||||
Check your terminal or start a new server with `{{ browserState ? `vitest --browser=${browserState.config.browser.name}` : 'vitest --ui' }}`
|
||||
Check your terminal or start a new server with `{{
|
||||
browserState
|
||||
? `vitest --browser=${browserState.config.browser.name}`
|
||||
: "vitest --ui"
|
||||
}}`
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,29 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
src: string
|
||||
}>()
|
||||
src: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div h="full" flex="~ col">
|
||||
<div
|
||||
p="3"
|
||||
h-10
|
||||
flex="~ gap-2"
|
||||
items-center
|
||||
bg-header
|
||||
border="b base"
|
||||
>
|
||||
<div p="3" h-10 flex="~ gap-2" items-center bg-header border="b base">
|
||||
<div class="i-carbon:folder-details-reference" />
|
||||
<span
|
||||
pl-1
|
||||
font-bold
|
||||
text-sm
|
||||
flex-auto
|
||||
ws-nowrap
|
||||
overflow-hidden
|
||||
truncate
|
||||
>Coverage</span>
|
||||
<span pl-1 font-bold text-sm flex-auto ws-nowrap overflow-hidden truncate
|
||||
>Coverage</span
|
||||
>
|
||||
</div>
|
||||
<div flex-auto py-1 bg-white>
|
||||
<iframe id="vitest-ui-coverage" :src="src" />
|
||||
|
||||
@ -1,23 +1,10 @@
|
||||
<template>
|
||||
<div h="full" flex="~ col">
|
||||
<div
|
||||
p="3"
|
||||
h-10
|
||||
flex="~ gap-2"
|
||||
items-center
|
||||
bg-header
|
||||
border="b base"
|
||||
>
|
||||
<div p="3" h-10 flex="~ gap-2" items-center bg-header border="b base">
|
||||
<div class="i-carbon-dashboard" />
|
||||
<span
|
||||
pl-1
|
||||
font-bold
|
||||
text-sm
|
||||
flex-auto
|
||||
ws-nowrap
|
||||
overflow-hidden
|
||||
truncate
|
||||
>Dashboard</span>
|
||||
<span pl-1 font-bold text-sm flex-auto ws-nowrap overflow-hidden truncate
|
||||
>Dashboard</span
|
||||
>
|
||||
</div>
|
||||
<div class="scrolls" flex-auto py-1>
|
||||
<TestsFilesContainer />
|
||||
|
||||
@ -1,14 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
color?: string
|
||||
}>()
|
||||
color?: string;
|
||||
}>();
|
||||
|
||||
const open = ref(true)
|
||||
const open = ref(true);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :open="open" class="details-panel" data-testid="details-panel" @toggle="open = $event.target.open">
|
||||
<div p="y1" text-sm bg-base items-center z-5 gap-2 :class="color" w-full flex select-none sticky top="-1">
|
||||
<div
|
||||
:open="open"
|
||||
class="details-panel"
|
||||
data-testid="details-panel"
|
||||
@toggle="open = $event.target.open"
|
||||
>
|
||||
<div
|
||||
p="y1"
|
||||
text-sm
|
||||
bg-base
|
||||
items-center
|
||||
z-5
|
||||
gap-2
|
||||
:class="color"
|
||||
w-full
|
||||
flex
|
||||
select-none
|
||||
sticky
|
||||
top="-1"
|
||||
>
|
||||
<div flex-1 h-1px border="base b" op80 />
|
||||
<slot name="summary" :open="open" />
|
||||
<div flex-1 h-1px border="base b" op80 />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user