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
27 changes: 26 additions & 1 deletion src/e2e/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ export interface StartServerOptions {
export async function startServer(options: StartServerOptions = {}) {
const ctx = useTestContext()
await stopServer()
if (ctx.disposed) {
throw new Error('Test context has been torn down; refusing to start a server.')
}
ctx.serverLogs = []
const host = '127.0.0.1'
const port = ctx.options.port || (await getRandomPort(host))
Expand Down Expand Up @@ -92,6 +95,13 @@ export async function startServer(options: StartServerOptions = {}) {
})().catch(() => {}))
}

// The context can be disposed while a caller was still building, in which
// case the process we just spawned would be orphaned.
if (ctx.disposed) {
await stopServer()
throw new Error('Test context has been torn down; server was stopped again.')
}

await waitForServer({ host, port, startedAt })
}

Expand Down Expand Up @@ -164,7 +174,16 @@ async function waitForServer({ host, port, startedAt }: WaitForServerOptions) {
await waitForPort(port, { retries: 8, host }).catch(() => {})

let lastError: unknown
let scannedLogs = 0
while (Date.now() < deadline) {
// `nuxi _dev` silently falls back to another port when the requested one is
// taken, so follow it rather than polling a URL nothing is listening on.
while (scannedLogs < ctx.serverLogs.length) {
const match = /Port \d+ is in use, using port (\d+) instead/.exec(ctx.serverLogs[scannedLogs++]!)
if (match) {
ctx.url = `http://${host}:${match[1]}/`
}
}
if (hasExited(ctx.serverProcess)) {
await flushServerLogs(ctx)
const error = earlyExitError(ctx, Date.now() - startedAt)
Expand Down Expand Up @@ -200,7 +219,13 @@ async function waitForServer({ host, port, startedAt }: WaitForServerOptions) {
error = earlyExitError(ctx, Date.now() - startedAt)
}
else {
error = new Error(`Timeout (${ctx.options.serverStartTimeout}ms) waiting for ${dev ? 'dev' : 'built'} server to become ready at ${ctx.url}`, { cause: lastError })
await flushServerLogs(ctx)
const output = ctx.serverLogs.slice(-REPLAYED_LOG_LINES).join('\n')
error = new Error(
`Timeout (${ctx.options.serverStartTimeout}ms) waiting for ${dev ? 'dev' : 'built'} server to become ready at ${ctx.url}`
+ (output ? `\n--- last output from the server process ---\n${output}` : ''),
{ cause: lastError },
)
}

await stopServer()
Expand Down
1 change: 1 addition & 0 deletions src/e2e/setup/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export function createTest(options: Partial<TestOptions>): TestHooks {
}

const afterAll = async () => {
ctx.disposed = true
// Every step is bounded against a single shared budget so that a shutdown
// that never settles degrades to a warning instead of failing the run.
const deadline = Date.now() + ctx.options.teardownTimeout
Expand Down
10 changes: 9 additions & 1 deletion src/e2e/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export interface TestOptions {
dev: boolean
/**
* The amount of time (in milliseconds) to allow for `setupTest` to complete its work (which could include building or generating files for a Nuxt application, depending on the options that are passed).
*
* This is also the effective deadline for the `@nuxt/test-utils/playwright` worker fixture.
* @default 120000 // or `240000` on windows
*/
setupTimeout: number
Expand All @@ -39,7 +41,7 @@ export interface TestOptions {
/**
* The amount of time (in milliseconds) to wait for the dev or built server to become ready (i.e. respond successfully on the configured base URL) before failing.
*
* This is bounded by `setupTimeout`, so increasing this is only useful in combination with a sufficiently large `setupTimeout`.
* This is bounded by `setupTimeout` (including in Playwright mode), so increasing this is only useful in combination with a sufficiently large `setupTimeout`.
* @default 120000 // on windows; otherwise 60000
*/
serverStartTimeout: number
Expand Down Expand Up @@ -93,6 +95,12 @@ export interface TestContext {
browser?: Browser
url?: string
serverProcess?: ReturnType<typeof exec>
/**
* Set when `afterAll` begins tearing down. A late-resolving `beforeAll` (for example one
* abandoned after a setup timeout) checks this so it does not orphan a
* server process after teardown.
*/
disposed?: boolean
Comment thread
coderabbitai[bot] marked this conversation as resolved.
mockFn?: (...args: unknown[]) => unknown
/**
* Lines emitted to the server subprocess's stdout/stderr, in order.
Expand Down
40 changes: 36 additions & 4 deletions src/playwright.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import defu from 'defu'
import { test as base } from '@playwright/test'
import type { Page, Response } from 'playwright-core'
import { isWindows } from 'std-env'
import type { GotoOptions, TestOptions as SetupOptions, TestHooks } from './e2e.ts'
import { createTest, url, waitForHydration } from './e2e.ts'

const FIXTURE_TIMEOUT = isWindows ? 120_000 : 60_000
// Playwright fixture options must be static, so they cannot read the resolved
// `setupTimeout`. This is only a backstop: the fixture below races its own
// `setupTimeout` deadline, which is what consumers actually configure. It has to
// stay above any plausible `setupTimeout` so Playwright never aborts us first,
// because Playwright skips fixture teardown when a fixture times out (which would
// orphan the dev server).
const FIXTURE_TIMEOUT_BACKSTOP = 30 * 60_000

// How long to wait for an abandoned `beforeAll` to settle before giving up on
// cleaning up whatever it spawned.
const ABANDONED_SETUP_GRACE = 30_000

export type ConfigOptions = {
nuxt: Partial<SetupOptions> | undefined
Expand Down Expand Up @@ -44,10 +53,33 @@ export const test = base.extend<TestOptions, WorkerOptions & ConfigOptions>({
_nuxtHooks: [
async ({ nuxt, defaults }, use) => {
const hooks = createTest(defu(nuxt || {}, defaults.nuxt || {}))
await hooks.beforeAll()
const { setupTimeout } = hooks.ctx.options

const setup = hooks.beforeAll()
let timer: ReturnType<typeof setTimeout> | undefined
const timedOut = await Promise.race([
setup.then(() => false),
new Promise<true>((resolve) => {
timer = setTimeout(() => resolve(true), setupTimeout)
}),
]).finally(() => clearTimeout(timer))

if (timedOut) {
// The abandoned setup may still spawn a server after this teardown, so
// wait for it to settle and tear down once more.
await hooks.afterAll()
await Promise.race([
setup.catch(() => {}),
new Promise(resolve => setTimeout(resolve, ABANDONED_SETUP_GRACE)),
])
await hooks.afterAll()
const logs = hooks.ctx.serverLogs.slice(-50).join('\n')
throw new Error(`Nuxt test setup timed out after ${setupTimeout}ms. Increase \`setupTimeout\` in your \`nuxt\` fixture options if this is expected.${logs ? `\n\nServer output:\n${logs}` : ''}`)
}

await use(hooks)
await hooks.afterAll()
}, { scope: 'worker', timeout: FIXTURE_TIMEOUT },
}, { scope: 'worker', timeout: FIXTURE_TIMEOUT_BACKSTOP },
],
baseURL: async ({ _nuxtHooks }, use) => {
_nuxtHooks.beforeEach()
Expand Down
17 changes: 17 additions & 0 deletions test/unit/server.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { createTestContext, setTestContext } from '../../src/e2e/context.ts'
import { startServer } from '../../src/e2e/server.ts'

describe('startServer', () => {
it('should refuse to start a server for a disposed context', async () => {
const ctx = createTestContext({ dev: false, build: false, browser: false })
ctx.disposed = true

try {
await expect(startServer()).rejects.toThrow(/torn down/)
}
finally {
setTestContext(undefined)
}
})
})
Loading