Skip to content

Commit f652605

Browse files
authored
fix: (vite-dev-server) wait for support file (#33487)
1 parent 593c22e commit f652605

4 files changed

Lines changed: 181 additions & 1 deletion

File tree

cli/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
**Bugfixes:**
99

1010
- Increased the limit for decrypted payloads to support large `cy.prompt` requests and responses. Fixed in [#33619](https://github.com/cypress-io/cypress/pull/33619).
11+
- Fixed a race condition in `@cypress/vite-dev-server` where the Cypress iframe could attempt to import the support file before Vite had finished serving it, causing intermittent "Failed to fetch dynamically imported module" errors in component tests. The dev server now waits until the support file URL returns a successful response before signaling that it is ready. Addressed in [#33487](https://github.com/cypress-io/cypress/pull/33487).
1112

1213
## 15.14.0
1314

npm/vite-dev-server/src/devServer.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import semverMajor from 'semver/functions/major.js'
33
import type { UserConfig } from 'vite-7'
44
import { getVite, Vite_7, Vite_8 } from './getVite.js'
55
import { createViteDevServerConfig, isVite8 } from './resolveConfig.js'
6+
import { getSupportFileRelativePath, waitUntilUrlReady } from './waitForSupportFile.js'
67

78
const debug = debugFn('cypress:vite-dev-server:devServer')
89

@@ -38,14 +39,25 @@ export async function devServer (config: ViteDevServerConfig): Promise<Cypress.R
3839
debug('Vite server created')
3940

4041
await server.listen()
41-
const { port } = server.config.server
42+
const { port, host } = server.config.server
4243

4344
if (!port) {
4445
throw new Error('Missing vite dev server port.')
4546
}
4647

4748
debug('Successfully launched the vite server on port', port)
4849

50+
const supportPath = getSupportFileRelativePath(config.cypressConfig)
51+
52+
if (supportPath) {
53+
const baseUrl = `http://${typeof host === 'string' ? host : '127.0.0.1'}:${port}`
54+
const supportFileUrl = new URL(supportPath, `${baseUrl}/`).href
55+
56+
debug('Waiting until support file is servable', supportFileUrl)
57+
await waitUntilUrlReady(supportFileUrl)
58+
debug('Support file is ready')
59+
}
60+
4961
return {
5062
port,
5163
// Close is for unit testing only. We kill this child process which will handle the closing of the server
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Wait until the Vite dev server can serve the support file (HTTP 200).
3+
* This makes the "dev server ready" signal deterministic and avoids the race
4+
* where the iframe loads before the server has finished serving the module.
5+
*/
6+
7+
const DEFAULT_MAX_ATTEMPTS = 30
8+
const DEFAULT_DELAY_MS = 200
9+
10+
interface WaitUntilUrlReadyOptions {
11+
maxAttempts?: number
12+
delayMs?: number
13+
}
14+
15+
/**
16+
* Build the support file path (same logic as client/initCypressTests.js)
17+
* so we poll the same URL the browser will request.
18+
*/
19+
export function getSupportFileRelativePath (cypressConfig: Cypress.PluginConfigOptions): string {
20+
const { projectRoot, supportFile, devServerPublicPathRoute } = cypressConfig
21+
22+
if (!supportFile) {
23+
return ''
24+
}
25+
26+
let supportRelativeToProjectRoot = supportFile.replace(projectRoot, '')
27+
28+
if (cypressConfig.platform === 'win32') {
29+
const platformProjectRoot = projectRoot.replace(/\//g, '\\')
30+
31+
supportRelativeToProjectRoot = supportFile.replace(platformProjectRoot, '')
32+
supportRelativeToProjectRoot = supportRelativeToProjectRoot.replace(/\\/g, '/')
33+
}
34+
35+
const devServerPublicPathBase = devServerPublicPathRoute === '' ? '.' : devServerPublicPathRoute
36+
37+
return `${devServerPublicPathBase}${supportRelativeToProjectRoot}`
38+
}
39+
40+
/**
41+
* Poll a URL until it returns a 2xx response or we exceed maxAttempts.
42+
*/
43+
export async function waitUntilUrlReady (
44+
url: string,
45+
options: WaitUntilUrlReadyOptions = {},
46+
): Promise<void> {
47+
const { maxAttempts = DEFAULT_MAX_ATTEMPTS, delayMs = DEFAULT_DELAY_MS } = options
48+
49+
let lastError: Error | undefined
50+
51+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
52+
try {
53+
const res = await fetch(url)
54+
55+
if (res.ok) {
56+
return
57+
}
58+
59+
lastError = new Error(`Support file URL returned ${res.status}`)
60+
} catch (err) {
61+
lastError = err instanceof Error ? err : new Error(String(err))
62+
}
63+
64+
if (attempt < maxAttempts) {
65+
await new Promise((resolve) => setTimeout(resolve, delayMs))
66+
}
67+
}
68+
69+
throw new Error(
70+
`Vite dev server did not become ready in time (${maxAttempts} attempts). ${lastError?.message ?? ''}`,
71+
)
72+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2+
import { getSupportFileRelativePath, waitUntilUrlReady } from '../src/waitForSupportFile'
3+
4+
describe('waitForSupportFile', () => {
5+
describe('getSupportFileRelativePath', () => {
6+
it('builds path matching client logic when devServerPublicPathRoute is set', () => {
7+
const cypressConfig = {
8+
projectRoot: '/users/proj',
9+
supportFile: '/users/proj/cypress/support/component.ts',
10+
devServerPublicPathRoute: '/__cypress/src',
11+
platform: 'darwin',
12+
} as Cypress.PluginConfigOptions
13+
14+
expect(getSupportFileRelativePath(cypressConfig)).toBe('/__cypress/src/cypress/support/component.ts')
15+
})
16+
17+
it('returns empty string when supportFile is not set', () => {
18+
const cypressConfig = {
19+
projectRoot: '/users/proj',
20+
supportFile: undefined,
21+
devServerPublicPathRoute: '/__cypress/src',
22+
platform: 'darwin',
23+
} as Cypress.PluginConfigOptions
24+
25+
expect(getSupportFileRelativePath(cypressConfig)).toBe('')
26+
})
27+
28+
it('handles win32 paths with backslashes', () => {
29+
const cypressConfig = {
30+
projectRoot: 'C:\\users\\proj',
31+
supportFile: 'C:\\users\\proj\\cypress\\support\\component.ts',
32+
devServerPublicPathRoute: '/__cypress/src',
33+
platform: 'win32',
34+
} as Cypress.PluginConfigOptions
35+
36+
expect(getSupportFileRelativePath(cypressConfig)).toBe('/__cypress/src/cypress/support/component.ts')
37+
})
38+
39+
it('uses relative path when devServerPublicPathRoute is empty', () => {
40+
const cypressConfig = {
41+
projectRoot: '/users/proj',
42+
supportFile: '/users/proj/cypress/support/component.ts',
43+
devServerPublicPathRoute: '',
44+
platform: 'darwin',
45+
} as Cypress.PluginConfigOptions
46+
47+
expect(getSupportFileRelativePath(cypressConfig)).toBe('./cypress/support/component.ts')
48+
})
49+
})
50+
51+
describe('waitUntilUrlReady', () => {
52+
beforeEach(() => {
53+
vi.stubGlobal('fetch', vi.fn())
54+
})
55+
56+
afterEach(() => {
57+
vi.unstubAllGlobals()
58+
})
59+
60+
it('resolves when URL returns 200', async () => {
61+
const fetchMock = vi.mocked(fetch)
62+
63+
fetchMock.mockResolvedValue({ ok: true } as Response)
64+
65+
await expect(waitUntilUrlReady('http://127.0.0.1:5173/__cypress/src/cypress/support/component.ts')).resolves.toBeUndefined()
66+
expect(fetchMock).toHaveBeenCalledTimes(1)
67+
})
68+
69+
it('retries until 200 then resolves', async () => {
70+
const fetchMock = vi.mocked(fetch)
71+
72+
fetchMock
73+
.mockResolvedValueOnce({ ok: false, status: 503 } as Response)
74+
.mockResolvedValueOnce({ ok: true } as Response)
75+
76+
await expect(
77+
waitUntilUrlReady('http://127.0.0.1:5173/ready', { maxAttempts: 3, delayMs: 1 }),
78+
).resolves.toBeUndefined()
79+
80+
expect(fetchMock).toHaveBeenCalledTimes(2)
81+
})
82+
83+
it('throws after maxAttempts if URL never returns 2xx', async () => {
84+
const fetchMock = vi.mocked(fetch)
85+
86+
fetchMock.mockResolvedValue({ ok: false, status: 404 } as Response)
87+
88+
await expect(
89+
waitUntilUrlReady('http://127.0.0.1:5173/missing', { maxAttempts: 2, delayMs: 1 }),
90+
).rejects.toThrow(/did not become ready in time/)
91+
92+
expect(fetchMock).toHaveBeenCalledTimes(2)
93+
})
94+
})
95+
})

0 commit comments

Comments
 (0)