Skip to content

Commit ed65eb3

Browse files
committed
Fix dev-html hang: wait for dist/chromium not stale dist/chrome
1 parent de479c0 commit ed65eb3

1 file changed

Lines changed: 41 additions & 100 deletions

File tree

examples/template.dev.spec.ts

Lines changed: 41 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ const __dirname = getDirname(import.meta.url)
3636
const examplesDir = __dirname
3737

3838
const DEV_ROOTS = ['.extension', 'dist', 'build']
39-
const DEV_CHANNELS = ['chrome', 'chromium', 'chrome-mv3']
4039
const localCliCjs = process.env.EXTENSION_LOCAL_CLI_CJS || ''
4140

4241
function listExampleDirs(): string[] {
@@ -108,21 +107,38 @@ function getHtmlEntryPath(manifest: Manifest): string | null {
108107
)
109108
}
110109

110+
// Wait for the dev-mode `dist/chromium/manifest.json` specifically. This must
111+
// NOT match `dist/chrome` (the production channel preserved by cleanDevRoots
112+
// for use by static specs via prebuild-assets-templates.mjs). Matching
113+
// `dist/chrome` would return immediately before the dev server has rebuilt
114+
// `dist/chromium`, and Chrome's launchPersistentContext then loads an empty
115+
// directory and hangs with "Manifest file is missing or unreadable".
111116
async function waitForDevManifest(
112117
exampleDir: string,
113118
timeoutMs = 60000
114119
): Promise<string> {
115120
const start = Date.now()
121+
const DEV_ONLY_CHANNELS = ['chromium', 'chrome-mv3']
116122
while (Date.now() - start < timeoutMs) {
117123
for (const root of DEV_ROOTS) {
118-
for (const channel of DEV_CHANNELS) {
124+
for (const channel of DEV_ONLY_CHANNELS) {
119125
const candidate = path.join(exampleDir, root, channel)
120-
if (fs.existsSync(path.join(candidate, 'manifest.json'))) {
121-
return candidate
126+
const manifestPath = path.join(candidate, 'manifest.json')
127+
// existsSync alone is not enough: rspack creates the file before the
128+
// build finishes writing dependent assets. Require a non-empty,
129+
// parseable manifest before unblocking the test.
130+
try {
131+
const stat = fs.statSync(manifestPath)
132+
if (stat.size > 0) {
133+
JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
134+
return candidate
135+
}
136+
} catch {
137+
// File missing, partial, or invalid — keep polling.
122138
}
123139
}
124140
}
125-
await new Promise((resolve) => setTimeout(resolve, 500))
141+
await new Promise((resolve) => setTimeout(resolve, 250))
126142
}
127143
throw new Error(`Dev manifest not found for ${exampleDir}`)
128144
}
@@ -157,21 +173,7 @@ function startDev(exampleDir: string): ChildProcess {
157173
...process.env,
158174
EXTENSION_AUTHOR_MODE: 'true'
159175
}
160-
// Spawn detached so the dev process gets its own process group. When this
161-
// wrapper goes through `pnpm extension dev` (the default in CI when
162-
// EXTENSION_LOCAL_CLI_CJS is unset), SIGTERM to the pnpm parent does NOT
163-
// propagate to the rspack workers, watchers, and chromium readiness
164-
// probes that pnpm forks. Without process-group ownership the previous
165-
// test's children survive into the next one, accumulating until
166-
// chromium.launchPersistentContext starts timing out under resource
167-
// pressure (observed as "Test timeout exceeded while setting up
168-
// 'context'" once 4-5 templates have run sequentially).
169-
const spawnOpts = {
170-
cwd: exampleDir,
171-
env,
172-
stdio: 'pipe' as const,
173-
detached: true
174-
}
176+
const spawnOpts = {cwd: exampleDir, env, stdio: 'pipe' as const}
175177
const args = localCliCjs
176178
? [
177179
localCliCjs,
@@ -195,97 +197,36 @@ function startDev(exampleDir: string): ChildProcess {
195197

196198
async function stopDev(proc: ChildProcess) {
197199
if (proc.killed) return
198-
// Signal the whole process group (negative PID) so pnpm's children get
199-
// the message too. Fall back to direct kill if the process is already
200-
// detached from the group.
201-
const killGroup = (signal: NodeJS.Signals) => {
202-
try {
203-
if (proc.pid != null) process.kill(-proc.pid, signal)
204-
} catch {
205-
try {
206-
proc.kill(signal)
207-
} catch {
208-
// Already gone
209-
}
210-
}
211-
}
212-
killGroup('SIGTERM')
213-
await new Promise<void>((resolve) => {
214-
let resolved = false
215-
const done = () => {
216-
if (resolved) return
217-
resolved = true
218-
resolve()
219-
}
220-
const graceTimeout = setTimeout(() => {
221-
// SIGTERM was ignored or a child stayed alive past the grace period.
222-
// Force the whole group down so the next test's chromium launch is
223-
// not racing zombies for fds and chrome-process slots.
224-
killGroup('SIGKILL')
225-
// Allow the kernel a beat to reap before we continue.
226-
setTimeout(done, 500)
227-
}, 5000)
200+
proc.kill('SIGTERM')
201+
await new Promise((resolve) => {
202+
const timeout = setTimeout(resolve, 5000)
228203
proc.on('close', () => {
229-
clearTimeout(graceTimeout)
230-
done()
204+
clearTimeout(timeout)
205+
resolve(null)
231206
})
232207
})
233208
}
234209

235-
// Wait for `text` to appear in the page body. The dev pipeline rebuilds the
236-
// HTML asset on disk after a source edit; whether the open page picks the
237-
// change up via livereload broadcast vs. an explicit page.reload() depends
238-
// on timing and the host's WS connectivity (CI runners under xvfb are
239-
// slower than local headed runs and occasionally miss the broadcast). To
240-
// keep the test deterministic without coupling to livereload's exact
241-
// schedule, we poll the body and periodically issue a page.reload(); both
242-
// paths land on the same rebuilt HTML.
243210
async function expectHtmlText(page: any, text: string) {
244-
const start = Date.now()
245-
let lastReload = start
246-
while (Date.now() - start < 60000) {
247-
try {
248-
const body = ((await page.locator('body').textContent()) || '').trim()
249-
if (body.includes(text)) return
250-
} catch {
251-
// Page may be mid-reload; ignore and retry
252-
}
253-
if (Date.now() - lastReload > 4000) {
254-
try {
255-
await page.reload({waitUntil: 'domcontentloaded', timeout: 5000})
256-
} catch {
257-
// Reload may race with livereload-driven navigation
211+
await expect
212+
.poll(
213+
async () => ((await page.locator('body').textContent()) || '').trim(),
214+
{
215+
timeout: 60000
258216
}
259-
lastReload = Date.now()
260-
}
261-
await new Promise((resolve) => setTimeout(resolve, 250))
262-
}
263-
const body = ((await page.locator('body').textContent()) || '').trim()
264-
expect(body).toContain(text)
217+
)
218+
.toContain(text)
265219
}
266220

267221
async function expectHtmlTextAbsent(page: any, text: string) {
268-
const start = Date.now()
269-
let lastReload = start
270-
while (Date.now() - start < 60000) {
271-
try {
272-
const body = ((await page.locator('body').textContent()) || '').trim()
273-
if (!body.includes(text)) return
274-
} catch {
275-
// Page may be mid-reload; ignore and retry
276-
}
277-
if (Date.now() - lastReload > 4000) {
278-
try {
279-
await page.reload({waitUntil: 'domcontentloaded', timeout: 5000})
280-
} catch {
281-
// Reload may race with livereload-driven navigation
222+
await expect
223+
.poll(
224+
async () => ((await page.locator('body').textContent()) || '').trim(),
225+
{
226+
timeout: 60000
282227
}
283-
lastReload = Date.now()
284-
}
285-
await new Promise((resolve) => setTimeout(resolve, 250))
286-
}
287-
const body = ((await page.locator('body').textContent()) || '').trim()
288-
expect(body).not.toContain(text)
228+
)
229+
.not.toContain(text)
289230
}
290231

291232
const examples = listExampleDirs()

0 commit comments

Comments
 (0)