Skip to content

Commit de479c0

Browse files
committed
Make dev-html spec resilient to livereload timing and dev-server orphans
1 parent 4f7d45e commit de479c0

1 file changed

Lines changed: 95 additions & 20 deletions

File tree

examples/template.dev.spec.ts

Lines changed: 95 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,21 @@ function startDev(exampleDir: string): ChildProcess {
157157
...process.env,
158158
EXTENSION_AUTHOR_MODE: 'true'
159159
}
160-
const spawnOpts = {cwd: exampleDir, env, stdio: 'pipe' as const}
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+
}
161175
const args = localCliCjs
162176
? [
163177
localCliCjs,
@@ -181,36 +195,97 @@ function startDev(exampleDir: string): ChildProcess {
181195

182196
async function stopDev(proc: ChildProcess) {
183197
if (proc.killed) return
184-
proc.kill('SIGTERM')
185-
await new Promise((resolve) => {
186-
const timeout = setTimeout(resolve, 5000)
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)
187228
proc.on('close', () => {
188-
clearTimeout(timeout)
189-
resolve(null)
229+
clearTimeout(graceTimeout)
230+
done()
190231
})
191232
})
192233
}
193234

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.
194243
async function expectHtmlText(page: any, text: string) {
195-
await expect
196-
.poll(
197-
async () => ((await page.locator('body').textContent()) || '').trim(),
198-
{
199-
timeout: 60000
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
200258
}
201-
)
202-
.toContain(text)
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)
203265
}
204266

205267
async function expectHtmlTextAbsent(page: any, text: string) {
206-
await expect
207-
.poll(
208-
async () => ((await page.locator('body').textContent()) || '').trim(),
209-
{
210-
timeout: 60000
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
211282
}
212-
)
213-
.not.toContain(text)
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)
214289
}
215290

216291
const examples = listExampleDirs()

0 commit comments

Comments
 (0)