@@ -36,7 +36,6 @@ const __dirname = getDirname(import.meta.url)
3636const examplesDir = __dirname
3737
3838const DEV_ROOTS = [ '.extension' , 'dist' , 'build' ]
39- const DEV_CHANNELS = [ 'chrome' , 'chromium' , 'chrome-mv3' ]
4039const localCliCjs = process . env . EXTENSION_LOCAL_CLI_CJS || ''
4140
4241function 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".
111116async 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
196198async 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.
243210async 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
267221async 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
291232const examples = listExampleDirs ( )
0 commit comments