Skip to content

Commit

Permalink
feat(browser): run test files in isolated iframes
Browse files Browse the repository at this point in the history
  • Loading branch information
sheremet-va committed Feb 1, 2024
1 parent 5cf4608 commit 4189f1a
Show file tree
Hide file tree
Showing 18 changed files with 334 additions and 439 deletions.
394 changes: 105 additions & 289 deletions packages/browser/src/client/main.ts

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions packages/browser/src/client/public/esm-client-injector.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,9 @@ window.__vi_module_cache__ = moduleCache
window.__vi_wrap_module__ = wrapModule

window.__vi_config__ = { __VITEST_CONFIG__ }
if (__vi_config__.testNamePattern)
__vi_config__.testNamePattern = parseRegexp(__vi_config__.testNamePattern)
if (window.__vi_config__.testNamePattern)
window.__vi_config__.testNamePattern = parseRegexp(window.__vi_config__.testNamePattern)
window.__vi_files__ = { __VITEST_FILES__ }

function parseRegexp(input) {
// Parse input
Expand Down
36 changes: 3 additions & 33 deletions packages/browser/src/client/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,40 +62,10 @@ export function createSafeRpc(client: VitestClient, getTimers: () => any): Vites
})
}

function serializeError(unhandledError: any) {
return {
...unhandledError,
name: unhandledError.name,
message: unhandledError.message,
stack: String(unhandledError.stack),
}
}

const url = new URL(location.href)
const reloadTries = Number(url.searchParams.get('reloadTries') || '0')

export async function loadSafeRpc(client: VitestClient) {
let safeRpc: typeof client.rpc
try {
// if importing /@id/ failed, we reload the page waiting until Vite prebundles it
const { getSafeTimers } = await importId('vitest/utils') as typeof import('vitest/utils')
safeRpc = createSafeRpc(client, getSafeTimers)
}
catch (err: any) {
if (reloadTries >= 10) {
const error = serializeError(new Error('Vitest failed to load "vitest/utils" after 10 retries.'))
error.cause = serializeError(err)

throw error
}

const tries = reloadTries + 1
const newUrl = new URL(location.href)
newUrl.searchParams.set('reloadTries', String(tries))
location.href = newUrl.href
return
}
return safeRpc
// if importing /@id/ failed, we reload the page waiting until Vite prebundles it
const { getSafeTimers } = await importId('vitest/utils') as typeof import('vitest/utils')
return createSafeRpc(client, getSafeTimers)
}

export function rpc(): VitestClient['rpc'] {
Expand Down
2 changes: 1 addition & 1 deletion packages/browser/src/client/tester.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@
</head>
<body>
<script type="module" src="/tester.ts"></script>
{__VITEST_TESTER__}
{__VITEST_APPEND__}
</body>
</html>
141 changes: 108 additions & 33 deletions packages/browser/src/client/tester.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,34 +6,57 @@ import { browserHashMap, initiateRunner } from './runner'
import { getConfig, importId } from './utils'
import { loadSafeRpc } from './rpc'
import { VitestBrowserClientMocker } from './mocker'
import { registerUnexpectedErrors, registerUnhandledErrors } from './unhandled'
import { registerUnexpectedErrors, registerUnhandledErrors, serializeError } from './unhandled'

const stopErrorHandler = registerUnhandledErrors()

const url = new URL(location.href)
const reloadStart = url.searchParams.get('__reloadStart')

async function runTest(filename: string) {
await client.waitForConnection()
function debug(...args: unknown[]) {
const debug = getConfig().env.VITEST_BROWSER_DEBUG
if (debug && debug !== 'false')
client.rpc.debug(...args.map(String))
}

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)
debug('failed to resolve runner', err?.message, 'trying again:', canTry, 'time is', now, 'reloadStart is', reloadStart)
if (!canTry) {
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')
return false
}

if (!reloadStart) {
const newUrl = new URL(location.href)
newUrl.searchParams.set('__reloadStart', now.toString())
debug('set the new url because reload start is not set to', newUrl)
location.href = newUrl.toString()
}
else {
debug('reload the iframe because reload start is set', location.href)
location.reload()
}
}
}

async function prepareTestEnvironment(files: string[]) {
debug('trying to resolve runner', `${reloadStart}`)
const config = getConfig()

const viteClientPath = `${config.base || '/'}@vite/client`
await import(viteClientPath)

let rpc: any
try {
rpc = await loadSafeRpc(client)
}
catch (error) {
await client.rpc.onUnhandledError(error, 'Reload Error')
channel.postMessage({ type: 'done', filename })
return
}

if (!rpc) {
channel.postMessage({ type: 'done', filename })
return
}
const rpc: any = await loadSafeRpc(client)

stopErrorHandler()
registerUnexpectedErrors(rpc)
Expand All @@ -47,7 +70,7 @@ async function runTest(filename: string) {
workerId: 1,
config,
projectName: config.name,
files: [filename],
files,
environment: {
name: 'browser',
options: null,
Expand Down Expand Up @@ -87,32 +110,84 @@ async function runTest(filename: string) {
const { startTests, setupCommonEnv } = await importId('vitest/browser') as typeof import('vitest/browser')

const version = url.searchParams.get('browserv') || '0'
const currentVersion = browserHashMap.get(filename)
if (!currentVersion || currentVersion[1] !== version)
browserHashMap.set(filename, [true, version])
files.forEach((filename) => {
const currentVersion = browserHashMap.get(filename)
if (!currentVersion || currentVersion[1] !== version)
browserHashMap.set(filename, [true, version])
})

const runner = await initiateRunner()

function removeBrowserChannel(event: BroadcastChannelEventMap['message']) {
if (event.data.type === 'disconnect' && filename === event.data.filename) {
channel.removeEventListener('message', removeBrowserChannel)
channel.close()
}
}
channel.addEventListener('message', removeBrowserChannel)

onCancel.then((reason) => {
runner.onCancel?.(reason)
})

return {
runner,
config,
state,
setupCommonEnv,
startTests,
}
}

function done(files: string[]) {
channel.postMessage({ type: 'done', filenames: files })
}

async function runTests(files: string[]) {
await client.waitForConnection()

debug('client is connected to ws server')

let preparedData: Awaited<ReturnType<typeof prepareTestEnvironment>> | undefined | false

// if importing /@id/ failed, we reload the page waiting until Vite prebundles it
try {
preparedData = await tryCall(() => prepareTestEnvironment(files))
}
catch (error) {
debug('data cannot be loaded becuase it threw an error')
await client.rpc.onUnhandledError(serializeError(error), 'Preload Error')
done(files)
return
}

// cannot load data, finish the test
if (preparedData === false) {
debug('data cannot be loaded, finishing the test')
done(files)
return
}

// page is reloading
if (!preparedData) {
debug('page is reloading, waiting for the next run')
return
}

debug('runner resolved successfully')

const { config, runner, state, setupCommonEnv, startTests } = preparedData

try {
await setupCommonEnv(config)
await startTests([filename], runner)
for (const file of files)
await startTests([file], runner)
}
finally {
channel.postMessage({ type: 'done', filename })
state.environmentTeardownRun = true
debug('finished running tests')
done(files)
}
}

// @ts-expect-error untyped global
globalThis.__vitest_browser_runner__ = { runTest }
async function invalid(id: string) {
channel.postMessage({ type: 'invalid', id })
}

// @ts-expect-error untyped global for internal use
window.__vitest_browser_runner__ = {
runTests,
invalid,
}
19 changes: 10 additions & 9 deletions packages/browser/src/client/unhandled.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,30 @@
import { client } from './client'
import type { client } from './client'
import { channel } from './client'
import { importId } from './utils'

const id = new URL(location.href).searchParams.get('__vitest_id')!

function on(event: string, listener: (...args: any[]) => void) {
window.addEventListener(event, listener)
return () => window.removeEventListener(event, listener)
}

function serializeError(unhandledError: any) {
export function serializeError(unhandledError: any) {
return {
...unhandledError,
name: unhandledError.name,
message: unhandledError.message,
stack: String(unhandledError.stack),
}
}

function getFiles(): string[] {
// @ts-expect-error this is set in injector
return window.__vi_running_tests__
}

// we can't import "processError" yet because error might've been thrown before the module was loaded
async function defaultErrorReport(type: string, unhandledError: any) {
const error = serializeError(unhandledError)
await client.rpc.onUnhandledError(error, type)
await client.rpc.onDone(id)
channel.postMessage({ type: 'error', files: getFiles(), error, errorType: type })
}

function catchWindowErrors(cb: (e: ErrorEvent) => void) {
Expand Down Expand Up @@ -67,7 +71,4 @@ async function reportUnexpectedError(rpc: typeof client.rpc, type: string, error
const { processError } = await importId('vitest/browser') as typeof import('vitest/browser')
const processedError = processError(error)
await rpc.onUnhandledError(processedError, type)
// TODO: don't fail if test is running
// if (!runningTests)
await rpc.onDone(id)
}
7 changes: 6 additions & 1 deletion packages/browser/src/client/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ResolvedConfig } from 'vitest'
import type { ResolvedConfig, WorkerGlobalState } from 'vitest'

export async function importId(id: string) {
const name = `${getConfig().base || '/'}@id/${id}`
Expand All @@ -10,3 +10,8 @@ export function getConfig(): ResolvedConfig {
// @ts-expect-error not typed global
return window.__vi_config__
}

export function getWorkerState(): WorkerGlobalState {
// @ts-expect-error not typed global
return window.__vi_worker_state__
}
45 changes: 24 additions & 21 deletions packages/browser/src/node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
const runnerHtml = readFile(resolve(distRoot, 'client/index.html'), 'utf8')
const injectorJs = readFile(resolve(distRoot, 'client/esm-client-injector.js'), '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) {
Expand All @@ -44,21 +45,21 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
if (!req.url)
return next()
const url = new URL(req.url, 'http://localhost')
if (!url.pathname.endsWith('__vitest_test__/tester.html') && url.pathname !== base)
if (!url.pathname.startsWith(testerPrefix) && url.pathname !== base)
return next()
const id = url.searchParams.get('__vitest_id')

// TODO: more handling, id is required
if (!id) {
res.statusCode = 404
res.end()
return
}

res.setHeader('Cache-Control', 'no-cache, max-age=0, must-revalidate')
res.setHeader('Content-Type', 'text/html; charset=utf-8')

const files = project.browserState?.files ?? []

const config = wrapConfig(project.getSerializableConfig())
config.env ??= {}
config.env.VITEST_BROWSER_DEBUG = process.env.VITEST_BROWSER_DEBUG || ''

const injector = replacer(await injectorJs, {
__VITEST_CONFIG__: JSON.stringify(wrapConfig(project.getSerializableConfig())),
__VITEST_CONFIG__: JSON.stringify(config),
__VITEST_FILES__: JSON.stringify(files),
})

if (url.pathname === base) {
Expand All @@ -72,20 +73,20 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
return
}

const testIndex = url.searchParams.get('__vitest_index')
const data = project.ctx.state.browserTestMap.get(id)
const test = testIndex && data?.paths[Number(testIndex)]
if (!test) {
res.statusCode = 404
res.end()
return
}
const decodedTestFile = decodeURIComponent(url.pathname.slice(testerPrefix.length))
// if decoded test file is "__vitest_all__" or not in the list of known files, run all tests
const tests = decodedTestFile === '__vitest_all__' || !files.includes(decodedTestFile) ? 'window.__vi_files__' : JSON.stringify([decodedTestFile])

const html = replacer(await testerHtml, {
__VITEST_FAVICON__: favicon,
__VITEST_TITLE__: test,
__VITEST_TEST__: test,
__VITEST_TITLE__: 'Vitest Browser Tester',
__VITEST_INJECTOR__: injector,
__VITEST_TESTER__: `<script type="module">await __vitest_browser_runner__.runTest("${test}", "${id}")</script>`,
__VITEST_APPEND__:
// TODO: have only a single global variable to not pollute the global scope
`<script type="module">
window.__vi_running_tests__ = ${tests}
__vitest_browser_runner__.runTests(window.__vi_running_tests__)
</script>`,
})
res.write(html, 'utf-8')
res.end()
Expand Down Expand Up @@ -116,9 +117,11 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
optimizeDeps: {
entries: [
...entries,
'vitest',
'vitest/utils',
'vitest/browser',
'vitest/runners',
'@vitest/utils',
],
exclude: [
'vitest',
Expand Down
1 change: 0 additions & 1 deletion packages/browser/src/node/providers/none.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { Awaitable } from 'vitest'
import type { BrowserProvider, WorkspaceProject } from 'vitest/node'

export class NoneBrowserProvider implements BrowserProvider {
Expand Down

0 comments on commit 4189f1a

Please sign in to comment.