Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/errors/VTDT0002.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ outline: deep

## Cause

After spawning `vitest --ui`, the launcher polls the Vitest UI URL until it responds, then swaps the dock entry to an iframe embedding it. This diagnostic is thrown when the server does not answer within the timeout — usually because Vitest failed to start (a config error, a missing dependency, or the process exiting early).
After spawning `vitest --ui`, the launcher polls the Vitest UI URL until it serves the app, then swaps the dock entry to an iframe embedding it. Vitest 5 serves the UI only with the API token it prints on startup (`UI started at …?token=…`), so the launcher reads that token from the session output and polls the tokenized URL; Vitest's `403` auth page does not count as ready. This diagnostic is thrown when the UI is not served within the timeout — usually because Vitest failed to start (a config error, a missing dependency, or the process exiting early), or because the `UI started at` line never appeared in its output.

## Example

Expand All @@ -21,4 +21,4 @@ Open the **Vitest UI** terminal session in the Terminals dock and read its outpu

## Source

- [`packages/vitest/src/node/plugin.ts`](https://github.com/vitejs/devtools/blob/main/packages/vitest/src/node/plugin.ts) — `onLaunch` polls the spawned server with `waitForServer()` and throws this when it stays unreachable past the timeout.
- [`packages/vitest/src/node/plugin.ts`](https://github.com/vitejs/devtools/blob/main/packages/vitest/src/node/plugin.ts) — `serve.onReady` polls the spawned server with `waitForVitestUi()` (from `ui-url.ts`) and throws this when the UI is not served past the timeout.
73 changes: 73 additions & 0 deletions packages/vitest/src/node/__tests__/ui-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { AddressInfo } from 'node:net'
import { createServer } from 'node:http'
import { afterEach, describe, expect, it } from 'vitest'
import { resolveVitestUiUrl, waitForVitestUi } from '../ui-url'

const TOKEN = '0c1f9d2e-5b7a-4c3e-9f10-2a6b8d4e7c11'

describe('resolveVitestUiUrl', () => {
const url = 'http://localhost:51204/__vitest__/'

it('returns undefined until Vitest prints the UI line', () => {
expect(resolveVitestUiUrl(url, '')).toBeUndefined()
expect(resolveVitestUiUrl(url, ' DEV v5.0.1 /repo\n')).toBeUndefined()
})

it('carries the printed token, ignoring ANSI colors', () => {
const output = ` DEV v5.0.1 /repo\n \x1B[2m\x1B[32mUI started at http://localhost:51204/__vitest__/?token=${TOKEN}\x1B[39m\x1B[22m\n`
expect(resolveVitestUiUrl(url, output)).toBe(`${url}?token=${TOKEN}`)
})

it('keeps the launcher URL when the printed URL has no token (Vitest < 5)', () => {
expect(resolveVitestUiUrl(url, 'UI started at http://localhost:51204/__vitest__/\n')).toBe(url)
})
})

describe('waitForVitestUi', () => {
let close: (() => Promise<void>) | undefined

afterEach(async () => {
await close?.()
close = undefined
})

// Mirrors Vitest 5's `vitestUiAuth` middleware: `403` without a valid
// token or cookie, `302` to the clean URL with a valid `?token=`.
async function startVitest5LikeServer(): Promise<string> {
const server = createServer((req, res) => {
const token = new URL(req.url ?? '/', 'http://localhost').searchParams.get('token')
if (token === TOKEN) {
res.writeHead(302, { 'Location': '/__vitest__/', 'Set-Cookie': `vitest-ui-token=${TOKEN}; Path=/__vitest__/; HttpOnly; SameSite=Strict` })
res.end()
return
}
res.statusCode = 403
res.end('Vitest UI requires authentication.')
})
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
close = () => new Promise(resolve => server.close(() => resolve()))
return `http://127.0.0.1:${(server.address() as AddressInfo).port}/__vitest__/`
}

it('does not treat the 403 auth page as ready', async () => {
const url = await startVitest5LikeServer()
expect(await waitForVitestUi(url, 500, () => '', 50)).toBeUndefined()
})

it('resolves with the tokenized URL once the token is printed', async () => {
const url = await startVitest5LikeServer()
let output = ''
setTimeout(() => {
output = `UI started at ${url}?token=${TOKEN}\n`
}, 150)
expect(await waitForVitestUi(url, 3000, () => output, 50)).toBe(`${url}?token=${TOKEN}`)
})

it('resolves with the plain URL for a server without auth', async () => {
const server = createServer((_req, res) => res.end('<html></html>'))
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
close = () => new Promise(resolve => server.close(() => resolve()))
const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/__vitest__/`
expect(await waitForVitestUi(url, 1000, () => '', 50)).toBe(url)
})
})
27 changes: 8 additions & 19 deletions packages/vitest/src/node/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { detect } from 'package-manager-detector/detect'
import { glob } from 'tinyglobby'
import { clientPublicDir } from '../dirs'
import { diagnostics } from './diagnostics'
import { waitForVitestUi } from './ui-url'

const VITEST_DEVTOOLS_BASE = '/__devtools-vitest/'
/** Path Vitest's own UI server serves its app under. */
Expand Down Expand Up @@ -130,10 +131,14 @@ export function DevToolsVitestUI(): PluginWithDevTools {
}
},
serve: {
onReady: async () => {
if (!(await waitForServer(url, READY_TIMEOUT)))
onReady: async (session) => {
// Vitest 5 gates its UI behind the API token it prints on
// startup; embed the tokenized URL so the iframe gets the UI
// rather than the `403` auth page.
const ready = await waitForVitestUi(url, READY_TIMEOUT, () => session.buffer?.join('') ?? '')
if (!ready)
throw diagnostics.VTDT0002({ url, timeout: READY_TIMEOUT })
return url
return ready
},
},
})
Expand Down Expand Up @@ -188,19 +193,3 @@ async function discoverRoots(cwd: string, workspaceRoot: string): Promise<DevToo

return [...roots.values()]
}

async function waitForServer(url: string, timeout: number): Promise<boolean> {
const start = Date.now()
while (Date.now() - start < timeout) {
try {
const res = await fetch(url)
if (res.status < 500)
return true
}
catch {
// server not up yet
}
await new Promise(resolve => setTimeout(resolve, 300))
}
return false
}
61 changes: 61 additions & 0 deletions packages/vitest/src/node/ui-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { stripVTControlCharacters } from 'node:util'

/**
* Carry the Vitest UI API token into `url`.
*
* Since Vitest 5 the UI answers `403` unless the request carries
* `?token=<api token>` (which sets an auth cookie and redirects to the clean
* URL) or already holds that cookie. Vitest prints the authenticated URL on
* startup as `UI started at http://localhost:<port>/__vitest__/?token=…`.
*
* Returns `url` with that token, `url` unchanged when the printed URL has no
* token (Vitest < 5), or `undefined` while the line hasn't been printed yet.
*/
export function resolveVitestUiUrl(url: string, output: string): string | undefined {
const printed = stripVTControlCharacters(output).match(/UI started at (\S+)/)?.[1]
if (!printed)
return undefined
let token: string | null
try {
token = new URL(printed).searchParams.get('token')
}
catch {
return undefined
}
if (!token)
return url
const authenticated = new URL(url)
authenticated.searchParams.set('token', token)
return authenticated.href
}

/**
* Poll the Vitest UI until it serves the app, returning the URL to embed
* (tokenized when Vitest printed a token), or `undefined` on timeout.
*
* Only a `2xx` or a `3xx` (Vitest redirects a valid `?token=` to the clean
* URL) counts as ready. A `403` is Vitest's auth-required page, which is not
* the UI, so polling continues until the token shows up in `getOutput()`.
*/
export async function waitForVitestUi(
url: string,
timeout: number,
getOutput: () => string,
interval = 300,
): Promise<string | undefined> {
const start = Date.now()
while (Date.now() - start < timeout) {
const target = resolveVitestUiUrl(url, getOutput()) ?? url
try {
const res = await fetch(target, { redirect: 'manual' })
await res.body?.cancel()
if (res.status >= 200 && res.status < 400)
return target
}
catch {
// server not up yet
}
await new Promise(resolve => setTimeout(resolve, interval))
}
return undefined
}
Loading