@@ -108,41 +108,63 @@ test.describe('Dashboard — multi-shift (3-5) round-trip regression guard', ()
108108 // — those bindings reflect the snapshot passed into the dialog, so each
109109 // new checkbox only materialises after a save + reopen cycle.
110110 for ( const id of [ 'thirdShiftActive' , 'fourthShiftActive' , 'fifthShiftActive' ] ) {
111+ // Wait for the GET that hydrates the dialog model BEFORE the dialog
112+ // even opens. `onFirstColumnClick` fires getAssignedSite() and only
113+ // then calls dialog.open(...), so this response gates whether the
114+ // *ngIf-gated checkbox (fourthShiftActive needs data.thirdShiftActive,
115+ // fifthShiftActive needs data.fourthShiftActive) is ever rendered.
116+ // Previously the test asserted on `mat-dialog-container` visible and
117+ // then hard-waited 10s for the gated input to attach; on slow CI the
118+ // dialog appeared before the GET committed and the *ngIf evaluated
119+ // false, blowing the wait. Awaiting the GET here is the deterministic
120+ // gate.
121+ const getAssignedSitePromise = page . waitForResponse (
122+ r => r . url ( ) . includes ( '/api/time-planning-pn/settings/assigned-sites' )
123+ && r . url ( ) . includes ( 'siteId=' )
124+ && r . request ( ) . method ( ) === 'GET' ,
125+ { timeout : 30000 } ) ;
111126 await page . locator ( '#firstColumn3' ) . click ( ) ;
112- await expect ( page . locator ( 'mat-dialog-container' ) ) . toBeVisible ( { timeout : 10000 } ) ;
127+ await getAssignedSitePromise ;
128+ await expect ( page . locator ( 'mat-dialog-container' ) ) . toBeVisible ( { timeout : 30000 } ) ;
113129
114130 const cb = page . locator ( `#${ id } input[type="checkbox"]` ) ;
115- await cb . waitFor ( { state : 'attached' , timeout : 10000 } ) ;
131+ // expect.toBeAttached() retries continuously and emits a richer error
132+ // log than locator.waitFor() — same observable contract, better flake
133+ // diagnostics.
134+ await expect ( cb ) . toBeAttached ( { timeout : 30000 } ) ;
116135 if ( ! ( await cb . isChecked ( ) ) ) {
117136 await page . locator ( `#${ id } ` ) . click ( { force : true } ) ;
118137 }
119- await expect ( cb ) . toBeChecked ( ) ;
138+ await expect ( cb ) . toBeChecked ( { timeout : 10000 } ) ;
120139
121140 const assignSitePromise = page . waitForResponse (
122- r => r . url ( ) . includes ( '/api/time-planning-pn/settings/assigned-site' ) && r . request ( ) . method ( ) === 'PUT' ) ;
141+ r => r . url ( ) . includes ( '/api/time-planning-pn/settings/assigned-site' ) && r . request ( ) . method ( ) === 'PUT' ,
142+ { timeout : 30000 } ) ;
123143 await page . locator ( '#saveButton' ) . click ( { force : true } ) ;
124144 await assignSitePromise ;
125145 await waitForSpinner ( page ) ;
126- await expect ( page . locator ( 'mat-dialog-container' ) ) . toHaveCount ( 0 , { timeout : 10000 } ) ;
146+ await expect ( page . locator ( 'mat-dialog-container' ) ) . toHaveCount ( 0 , { timeout : 15000 } ) ;
127147 }
128148
129149 // Day cell id is `cell{rowIndex}_{colField}` — row 3 matches the worker
130150 // whose assigned-site row (#firstColumn3) we just configured above.
131151 const cellId = '#cell3_0' ;
132152 await page . locator ( cellId ) . scrollIntoViewIfNeeded ( ) ;
133153 await page . locator ( cellId ) . click ( ) ;
134- await expect ( page . locator ( '#planHours' ) ) . toBeVisible ( ) ;
154+ await expect ( page . locator ( '#planHours' ) ) . toBeVisible ( { timeout : 15000 } ) ;
135155
136156 // Fill all 5 shifts.
137157 for ( const s of allFiveShifts ) {
138158 await setShift ( page , s . id , s . start , s . end , s . break ) ;
139159 }
140160
141161 // Save.
142- const updatePromise = page . waitForResponse ( r =>
143- r . url ( ) . includes ( '/api/time-planning-pn/plannings/' ) && r . request ( ) . method ( ) === 'PUT' ) ;
144- const reindexPromise = page . waitForResponse ( r =>
145- r . url ( ) . includes ( '/api/time-planning-pn/plannings/index' ) && r . request ( ) . method ( ) === 'POST' ) ;
162+ const updatePromise = page . waitForResponse (
163+ r => r . url ( ) . includes ( '/api/time-planning-pn/plannings/' ) && r . request ( ) . method ( ) === 'PUT' ,
164+ { timeout : 30000 } ) ;
165+ const reindexPromise = page . waitForResponse (
166+ r => r . url ( ) . includes ( '/api/time-planning-pn/plannings/index' ) && r . request ( ) . method ( ) === 'POST' ,
167+ { timeout : 30000 } ) ;
146168 await page . locator ( '#saveButton' ) . click ( ) ;
147169 await updatePromise ;
148170 await reindexPromise ;
@@ -153,21 +175,25 @@ test.describe('Dashboard — multi-shift (3-5) round-trip regression guard', ()
153175 // this is the bit that failed before the fix: shifts 3-5 came back as 00:00.
154176 await page . locator ( cellId ) . scrollIntoViewIfNeeded ( ) ;
155177 await page . locator ( cellId ) . click ( ) ;
156- await expect ( page . locator ( '#planHours' ) ) . toBeVisible ( ) ;
178+ await expect ( page . locator ( '#planHours' ) ) . toBeVisible ( { timeout : 15000 } ) ;
157179
180+ // Per-assertion 30s timeouts — the dialog opens before the form's
181+ // reactive bindings finish hydrating from the just-saved planning,
182+ // so the 5s default toHaveValue retry can race the bind. The
183+ // assertion contract is unchanged; only the retry budget grew.
158184 for ( const s of allFiveShifts ) {
159185 await expect (
160186 page . locator ( `[data-testid="plannedStartOfShift${ s . id } "]` ) ,
161187 `shift ${ s . id } start should round-trip`
162- ) . toHaveValue ( s . start ) ;
188+ ) . toHaveValue ( s . start , { timeout : 30000 } ) ;
163189 await expect (
164190 page . locator ( `[data-testid="plannedEndOfShift${ s . id } "]` ) ,
165191 `shift ${ s . id } end should round-trip`
166- ) . toHaveValue ( s . end ) ;
192+ ) . toHaveValue ( s . end , { timeout : 30000 } ) ;
167193 await expect (
168194 page . locator ( `[data-testid="plannedBreakOfShift${ s . id } "]` ) ,
169195 `shift ${ s . id } break should round-trip`
170- ) . toHaveValue ( s . break ) ;
196+ ) . toHaveValue ( s . break , { timeout : 30000 } ) ;
171197 }
172198
173199 await page . locator ( '#cancelButton' ) . click ( ) ;
@@ -190,14 +216,22 @@ test.describe('Dashboard — multi-shift (3-5) round-trip regression guard', ()
190216
191217 // Open the assigned-site dialog for the third worker (matches the
192218 // multishift test's #firstColumn3 convention so the two tests don't
193- // clobber each other across the same shard).
219+ // clobber each other across the same shard). Await the
220+ // getAssignedSite GET so the dialog opens with hydrated data — the
221+ // gated *ngIf inputs only attach once `data.*` is bound.
222+ const getAssignedSitePromise = page . waitForResponse (
223+ r => r . url ( ) . includes ( '/api/time-planning-pn/settings/assigned-sites' )
224+ && r . url ( ) . includes ( 'siteId=' )
225+ && r . request ( ) . method ( ) === 'GET' ,
226+ { timeout : 30000 } ) ;
194227 await page . locator ( '#firstColumn3' ) . click ( ) ;
195- await expect ( page . locator ( 'mat-dialog-container' ) ) . toBeVisible ( { timeout : 10000 } ) ;
228+ await getAssignedSitePromise ;
229+ await expect ( page . locator ( 'mat-dialog-container' ) ) . toBeVisible ( { timeout : 30000 } ) ;
196230
197231 // The entry-method + editing-policy radios are gated behind
198232 // allowPersonalTimeRegistration. Make sure it's enabled (idempotent).
199233 const personalCb = page . locator ( '#allowPersonalTimeRegistration input[type="checkbox"]' ) ;
200- await personalCb . waitFor ( { state : 'attached' , timeout : 10000 } ) ;
234+ await expect ( personalCb ) . toBeAttached ( { timeout : 30000 } ) ;
201235 if ( ! ( await personalCb . isChecked ( ) ) ) {
202236 await page . locator ( '#allowPersonalTimeRegistration' ) . click ( { force : true } ) ;
203237 }
@@ -221,15 +255,24 @@ test.describe('Dashboard — multi-shift (3-5) round-trip regression guard', ()
221255
222256 // Save and wait for the PUT to land + the dashboard re-index.
223257 const assignSitePromise = page . waitForResponse (
224- r => r . url ( ) . includes ( '/api/time-planning-pn/settings/assigned-site' ) && r . request ( ) . method ( ) === 'PUT' ) ;
258+ r => r . url ( ) . includes ( '/api/time-planning-pn/settings/assigned-site' ) && r . request ( ) . method ( ) === 'PUT' ,
259+ { timeout : 30000 } ) ;
225260 await page . locator ( '#saveButton' ) . click ( { force : true } ) ;
226261 await assignSitePromise ;
227262 await waitForSpinner ( page ) ;
228- await expect ( page . locator ( 'mat-dialog-container' ) ) . toHaveCount ( 0 , { timeout : 10000 } ) ;
229-
230- // Re-open the dialog and assert both choices round-tripped.
263+ await expect ( page . locator ( 'mat-dialog-container' ) ) . toHaveCount ( 0 , { timeout : 15000 } ) ;
264+
265+ // Re-open the dialog and assert both choices round-tripped. Wait for
266+ // the freshly-fetched assigned-site GET so the radios bind to the
267+ // persisted values before we assert.
268+ const getAssignedSitePromise2 = page . waitForResponse (
269+ r => r . url ( ) . includes ( '/api/time-planning-pn/settings/assigned-sites' )
270+ && r . url ( ) . includes ( 'siteId=' )
271+ && r . request ( ) . method ( ) === 'GET' ,
272+ { timeout : 30000 } ) ;
231273 await page . locator ( '#firstColumn3' ) . click ( ) ;
232- await expect ( page . locator ( 'mat-dialog-container' ) ) . toBeVisible ( { timeout : 10000 } ) ;
274+ await getAssignedSitePromise2 ;
275+ await expect ( page . locator ( 'mat-dialog-container' ) ) . toBeVisible ( { timeout : 30000 } ) ;
233276
234277 // acceptPlanned still selected.
235278 await expect (
@@ -267,15 +310,23 @@ test.describe('Dashboard — multi-shift (3-5) round-trip regression guard', ()
267310
268311 // Open the assigned-site dialog for the third worker — same convention
269312 // as the other tests on this shard so they don't clobber each other.
313+ // Await the GET that hydrates the dialog so first-user-gated toggle
314+ // attaches synchronously after open.
315+ const getAssignedSitePromise = page . waitForResponse (
316+ r => r . url ( ) . includes ( '/api/time-planning-pn/settings/assigned-sites' )
317+ && r . url ( ) . includes ( 'siteId=' )
318+ && r . request ( ) . method ( ) === 'GET' ,
319+ { timeout : 30000 } ) ;
270320 await page . locator ( '#firstColumn3' ) . click ( ) ;
271- await expect ( page . locator ( 'mat-dialog-container' ) ) . toBeVisible ( { timeout : 10000 } ) ;
321+ await getAssignedSitePromise ;
322+ await expect ( page . locator ( 'mat-dialog-container' ) ) . toBeVisible ( { timeout : 30000 } ) ;
272323
273324 // The toggle is gated behind first-user; the CI fixture logs in as
274325 // first-user, so it must be visible.
275- await expect ( page . locator ( '#useOneMinuteIntervals' ) ) . toBeVisible ( { timeout : 10000 } ) ;
326+ await expect ( page . locator ( '#useOneMinuteIntervals' ) ) . toBeVisible ( { timeout : 30000 } ) ;
276327
277328 const cb = page . locator ( '#useOneMinuteIntervals input[type="checkbox"]' ) ;
278- await cb . waitFor ( { state : 'attached' , timeout : 10000 } ) ;
329+ await expect ( cb ) . toBeAttached ( { timeout : 30000 } ) ;
279330
280331 // Capture the starting state, flip it, save, re-open, assert it persisted.
281332 const wasChecked = await cb . isChecked ( ) ;
@@ -287,22 +338,32 @@ test.describe('Dashboard — multi-shift (3-5) round-trip regression guard', ()
287338 }
288339
289340 const assignSitePromise = page . waitForResponse (
290- r => r . url ( ) . includes ( '/api/time-planning-pn/settings/assigned-site' ) && r . request ( ) . method ( ) === 'PUT' ) ;
341+ r => r . url ( ) . includes ( '/api/time-planning-pn/settings/assigned-site' ) && r . request ( ) . method ( ) === 'PUT' ,
342+ { timeout : 30000 } ) ;
291343 await page . locator ( '#saveButton' ) . click ( { force : true } ) ;
292344 await assignSitePromise ;
293345 await waitForSpinner ( page ) ;
294- await expect ( page . locator ( 'mat-dialog-container' ) ) . toHaveCount ( 0 , { timeout : 10000 } ) ;
295-
296- // Re-open and assert the new value round-tripped.
346+ await expect ( page . locator ( 'mat-dialog-container' ) ) . toHaveCount ( 0 , { timeout : 15000 } ) ;
347+
348+ // Re-open and assert the new value round-tripped. Await the
349+ // post-save GET so the freshly-persisted toggle is bound before we
350+ // assert (prior failure: line 305 `toBeChecked` saw the pre-bind
351+ // unchecked state in a 5s window).
352+ const getAssignedSitePromise2 = page . waitForResponse (
353+ r => r . url ( ) . includes ( '/api/time-planning-pn/settings/assigned-sites' )
354+ && r . url ( ) . includes ( 'siteId=' )
355+ && r . request ( ) . method ( ) === 'GET' ,
356+ { timeout : 30000 } ) ;
297357 await page . locator ( '#firstColumn3' ) . click ( ) ;
298- await expect ( page . locator ( 'mat-dialog-container' ) ) . toBeVisible ( { timeout : 10000 } ) ;
358+ await getAssignedSitePromise2 ;
359+ await expect ( page . locator ( 'mat-dialog-container' ) ) . toBeVisible ( { timeout : 30000 } ) ;
299360
300361 const cbReopened = page . locator ( '#useOneMinuteIntervals input[type="checkbox"]' ) ;
301- await cbReopened . waitFor ( { state : 'attached' , timeout : 10000 } ) ;
362+ await expect ( cbReopened ) . toBeAttached ( { timeout : 30000 } ) ;
302363 if ( wasChecked ) {
303- await expect ( cbReopened ) . not . toBeChecked ( ) ;
364+ await expect ( cbReopened ) . not . toBeChecked ( { timeout : 15000 } ) ;
304365 } else {
305- await expect ( cbReopened ) . toBeChecked ( ) ;
366+ await expect ( cbReopened ) . toBeChecked ( { timeout : 15000 } ) ;
306367 }
307368
308369 await page . locator ( '#cancelButton' ) . click ( ) ;
0 commit comments