Skip to content

Commit 07878ef

Browse files
authored
Merge pull request #1557 from microting/fix/multishift-round-trip-flake
fix(playwright): stabilize multishift round-trip locator wait
2 parents 7aef4ec + 07fcdcf commit 07878ef

1 file changed

Lines changed: 94 additions & 33 deletions

File tree

eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts

Lines changed: 94 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)