@@ -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
182196async 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.
194243async 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
205267async 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
216291const examples = listExampleDirs ( )
0 commit comments