From 05392dfe280ed80bbd8042d7189e38ba0734a173 Mon Sep 17 00:00:00 2001 From: zahid emre zorbaz Date: Thu, 24 Sep 2026 12:23:04 +0300 Subject: [PATCH] fix(vitest): embed the tokenized Vitest UI URL Vitest 5 answers `/__vitest__/` with a `403` auth page unless the request carries the API token printed on startup. The launcher embedded the bare URL and its readiness probe accepted any status below 500, so the dock iframe showed the auth page. Read the token from the session output, embed the tokenized URL, and only treat a `2xx`/`3xx` as ready. Fixes #585 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/errors/VTDT0002.md | 4 +- .../vitest/src/node/__tests__/ui-url.test.ts | 73 +++++++++++++++++++ packages/vitest/src/node/plugin.ts | 27 ++----- packages/vitest/src/node/ui-url.ts | 61 ++++++++++++++++ 4 files changed, 144 insertions(+), 21 deletions(-) create mode 100644 packages/vitest/src/node/__tests__/ui-url.test.ts create mode 100644 packages/vitest/src/node/ui-url.ts diff --git a/docs/errors/VTDT0002.md b/docs/errors/VTDT0002.md index 13059fa49..8e137abb2 100644 --- a/docs/errors/VTDT0002.md +++ b/docs/errors/VTDT0002.md @@ -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 @@ -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. diff --git a/packages/vitest/src/node/__tests__/ui-url.test.ts b/packages/vitest/src/node/__tests__/ui-url.test.ts new file mode 100644 index 000000000..cfc37fb76 --- /dev/null +++ b/packages/vitest/src/node/__tests__/ui-url.test.ts @@ -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) | 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 { + 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(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('')) + await new Promise(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) + }) +}) diff --git a/packages/vitest/src/node/plugin.ts b/packages/vitest/src/node/plugin.ts index 0f7d0b9e7..cfef34cb8 100644 --- a/packages/vitest/src/node/plugin.ts +++ b/packages/vitest/src/node/plugin.ts @@ -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. */ @@ -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 }, }, }) @@ -188,19 +193,3 @@ async function discoverRoots(cwd: string, workspaceRoot: string): Promise { - 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 -} diff --git a/packages/vitest/src/node/ui-url.ts b/packages/vitest/src/node/ui-url.ts new file mode 100644 index 000000000..4e69891b7 --- /dev/null +++ b/packages/vitest/src/node/ui-url.ts @@ -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=` (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:/__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 { + 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 +}