Skip to content

Commit 43303f9

Browse files
committed
test(qa): wait on loaded grid rows, not the header, to kill the flake (om-yd1h)
EditableGrid renders its <thead> at mount but fills <tbody> from an async load() ($effect). Every grid check waited on `section table thead th` — up the instant the grid mounts, BEFORE the rows land — then immediately read the row count / tbody / a <datalist>. When the fetch hadn't resolved it sampled an empty grid: both known flakes ('source-type inert on return rows' and 'mixed return denom binds to Mixed') and five latent read sites are the same defect. Hoist and generalize om-lv4q's per-block awaitRowCount into a shared settle helper (gate on tbody tr[data-row-index], the real rows — the draft row has no index) and wait on it at every grid read: Losses round-trip, the roll-txns source/denom block, the goHoldings helper (covers the location + datalist checks), and the roll-txns Bank datalist. The Supplies delete-confirm checks keep exact-count semantics via the same helper. Also retire the four fixed-duration waitForTimeout crutches, which stood in for settle-state waits: poll the REST endpoint after a write (awaitApi), wait on the PUT response after a grid commit, and wait for the row to detach after a virtualization scroll. Fix the WAIT, never the timeout — a bumped timeout only makes a flake rarer, i.e. worse. Closes om-1gmy.
1 parent 17a8bf1 commit 43303f9

1 file changed

Lines changed: 61 additions & 21 deletions

File tree

qa/do-tab.e2e.mjs

Lines changed: 61 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,39 @@ const goDo = async () => {
5353
}
5454
const tile = (name) => page.locator('button.group', { hasText: name })
5555

56+
// EditableGrid renders its <thead> at mount but fills <tbody> from an async load()
57+
// ($effect in EditableGrid.svelte) — so a check that waits on the header and then reads
58+
// rows/tbody/a <datalist> races that fetch and can sample an EMPTY grid. That one defect
59+
// is behind both known flakes (source-type-inert, mixed-denom) and every latent grid read
60+
// (om-yd1h). Fix the WAIT, never the timeout: gate on the loaded rows. Real rows carry
61+
// data-row-index; the trailing draft row does not, so this counts exactly what load()
62+
// produced. `exact` is for AFTER a delete, where the count DROPS and a `>=` would
63+
// short-circuit on the pre-delete rows.
64+
const dataRowSel = 'section table tbody tr[data-row-index]'
65+
const awaitRowCount = (n, { sel = dataRowSel, exact = false } = {}) =>
66+
page
67+
.waitForFunction(
68+
([s, want, eq]) => {
69+
const c = document.querySelectorAll(s).length
70+
return eq ? c === want : c >= want
71+
},
72+
[sel, n, exact],
73+
{ timeout: 5000 },
74+
)
75+
.catch(() => {}) // let the ok() below report the miss, with real numbers
76+
77+
// Poll a REST endpoint until it settles, instead of sleeping a fixed guess after a write.
78+
// Returns the last value seen either way, so the ok() reports real state on a miss.
79+
const awaitApi = async (path, pred, { tries = 60, gap = 100 } = {}) => {
80+
let v
81+
for (let i = 0; i < tries; i++) {
82+
v = await api(path)
83+
if (pred(v)) break
84+
await new Promise((r) => setTimeout(r, gap))
85+
}
86+
return v
87+
}
88+
5689
try {
5790
await page.goto(BASE, { waitUntil: 'networkidle' })
5891
await page.getByRole('heading', { name: 'CoinRollHunter' }).waitFor({ timeout: 8000 })
@@ -146,7 +179,7 @@ try {
146179
// step 1: a forgotten keeper (5 halves = $2.50) shrinks the float — NOT a loss
147180
await page.getByRole('spinbutton').nth(0).fill('5')
148181
await page.getByRole('button', { name: 'Add', exact: true }).first().click()
149-
await page.waitForTimeout(400)
182+
await awaitApi('/keepers', (k) => k.length === 2) // wait for the write, not a fixed guess
150183
ok('reconcile recorded forgotten keeper', (await api('/keepers')).length === 2)
151184
const sumMid = await api('/summary')
152185
ok('keeper reduced float (not a loss)', (sumMid.losses ?? 0) === 0 && sumMid.to_redeposit < sumBefore.to_redeposit,
@@ -179,8 +212,9 @@ try {
179212
await page.getByRole('button', { name: 'Edit' }).click()
180213
await page.getByRole('button', { name: 'Losses', exact: true }).click()
181214
await page.locator('section table thead th').first().waitFor({ timeout: 5000 })
182-
const lossRows = await page.locator('section table tbody tr:has(button[title="Delete row"])').count()
183215
const apiLosses = (await api('/losses')).length
216+
await awaitRowCount(apiLosses) // the header is up; wait for load() to land the rows
217+
const lossRows = await page.locator('section table tbody tr:has(button[title="Delete row"])').count()
184218
ok('Losses grid round-trips', lossRows === apiLosses && apiLosses >= 1, `dom ${lossRows} vs api ${apiLosses}`)
185219

186220
// === Overview reflects shrinkage ===
@@ -196,8 +230,7 @@ try {
196230
(await page.getByText('Buys', { exact: true }).count()) > 0 &&
197231
(await page.getByText('Branches', { exact: true }).count()) > 0 &&
198232
(await page.getByText('Avg buy', { exact: true }).count()) > 0)
199-
// spot freshness chip — source label varies (manual seed vs. the ADR-007 poller),
200-
// so match the chip by its unique title, not the source string.
233+
// spot freshness chip — matched by its unique (static) title attribute.
201234
ok('spot freshness chip visible (ADR-007)', await page.locator('span[title*="background"]').first().isVisible())
202235
// hit-rate report: the endpoint (data) plus the grid, which lives on the
203236
// Insights tab — the analysis altitude was lifted out of Overview in the ADR-012
@@ -221,8 +254,9 @@ try {
221254
await newRow.getByPlaceholder('Mercury').fill('Mercury')
222255
await newRow.locator('input[type=checkbox]').check()
223256
await newRow.locator('button[title="Add row"]').click()
224-
await page.waitForTimeout(500)
225-
const taxLot = (await api('/lots')).find((l) => l.category === 'Silver' && l.subcategory === 'Mercury' && l.trophy === true)
257+
const isTaxLot = (l) => l.category === 'Silver' && l.subcategory === 'Mercury' && l.trophy === true
258+
await awaitApi('/lots', (ls) => ls.some(isTaxLot)) // wait for create+reload, not a fixed guess
259+
const taxLot = (await api('/lots')).find(isTaxLot)
226260
ok('Holdings taxonomy persists (category/subcategory/trophy)', !!taxLot, taxLot ? `lot ${taxLot.id}` : 'not found')
227261
// The new find has basis 0 (grid default) — summary must still serialize (no +Inf in unreal_pct).
228262
const sumZeroBasis = await api('/summary')
@@ -266,6 +300,7 @@ try {
266300
await page.getByRole('button', { name: 'Edit' }).click()
267301
await page.getByRole('button', { name: 'Roll txns', exact: true }).click()
268302
await page.locator('section table thead th').first().waitFor({ timeout: 5000 })
303+
await awaitRowCount((await api('/roll-txns')).length) // wait for the rows before reading them
269304
// cells: date(0) bank(1) action(2) denom(3) unit(4) source(5); the first select
270305
// in a row is the action select. The draft row defaults to 'buy' so it's excluded.
271306
const returnSourceCells = await page.$$eval('section table tbody tr', (rows) =>
@@ -301,14 +336,22 @@ try {
301336
await page.getByRole('button', { name: 'Edit', exact: true }).click()
302337
await page.getByRole('button', { name: 'Holdings', exact: true }).click()
303338
await page.locator('section table thead th').first().waitFor({ timeout: 5000 })
339+
// The header is up but rows arrive from an async load(); wait for at least one real
340+
// row (which also means the suggestion caches behind the datalists have landed).
341+
await awaitRowCount(1)
304342
}
305343
// Existing rows only — the trailing new-row draft has no Delete button.
306344
const locCells = () =>
307345
page.locator('tbody tr:has(button[title="Delete row"]) input[list="dl-holdings-location"]')
308346
const commit = async (input, value) => {
309347
await input.fill(value)
310-
await input.blur() // onchange → saveRow
311-
await page.waitForTimeout(500)
348+
// blur fires onchange → saveRow → PUT /api/lots/{id}; wait for that write to land,
349+
// not a fixed guess, before the caller re-reads the row from the API.
350+
const saved = page
351+
.waitForResponse((r) => r.request().method() === 'PUT' && /\/api\/lots\//.test(r.url()), { timeout: 5000 })
352+
.catch(() => {})
353+
await input.blur()
354+
await saved
312355
}
313356

314357
await goHoldings()
@@ -445,6 +488,7 @@ try {
445488
// Bank, on a different grid: the branch you bought from must suggest itself back.
446489
await page.getByRole('button', { name: 'Roll txns', exact: true }).click()
447490
await page.locator('section table thead th').first().waitFor({ timeout: 5000 })
491+
await awaitRowCount((await api('/roll-txns')).length) // wait for load() to fill the bank suggestions
448492
// id is derived from the grid TITLE ('Roll transactions'), not the tab label.
449493
const bankOpts = await dlOptions('dl-roll-transactions-bank')
450494
const usedBanks = [...new Set((await api('/roll-txns')).map((r) => r.bank).filter(Boolean))]
@@ -538,9 +582,12 @@ try {
538582
await page.evaluate(() => {
539583
document.querySelector('section table').parentElement.scrollTop = 15000
540584
})
541-
await page.waitForTimeout(800)
585+
// Scrolling row 0 out of the window blurs its editor (commitIfLeaving) and unmounts it;
586+
// wait for the unmount, then for the blur-triggered PUT to land — not a fixed guess.
587+
await page.locator('tbody tr[data-row-index="0"]').waitFor({ state: 'detached', timeout: 5000 }).catch(() => {})
542588
ok('the edited row really did unmount (otherwise the guard is untested)',
543589
(await page.locator('tbody tr[data-row-index="0"]').count()) === 0)
590+
await awaitApi('/lots', (ls) => ls.find((l) => l.id === editId)?.qty === 4242)
544591
const afterScroll = (await api('/lots')).find((l) => l.id === editId)
545592
ok('an in-flight grid edit survives its row being virtualized away',
546593
afterScroll?.qty === 4242, `db qty ${afterScroll?.qty} (expected 4242)`)
@@ -579,20 +626,13 @@ try {
579626
}
580627
const confirmDlg = page.getByRole('dialog')
581628
const cancelBtn = () => confirmDlg.getByRole('button', { name: 'Cancel', exact: true })
582-
const awaitRowCount = (n) =>
583-
page
584-
.waitForFunction(
585-
([sel, want]) => document.querySelectorAll(sel).length === want,
586-
[supplyRowSel, n],
587-
{ timeout: 5000 },
588-
)
589-
.catch(() => {}) // let the ok() below report the miss, with numbers
590629

591630
// Wait for the async GET /api/supplies to populate the grid BEFORE counting — the
592631
// `thead th` we waited on renders before load() resolves, so a bare count here would
593-
// race the fetch and could read 0 (the flake class tracked in om-yd1h). We seeded
594-
// exactly two rows into this otherwise-untouched grid, so 2 is the settled count.
595-
await awaitRowCount(2)
632+
// race the fetch and could read 0 (the flake class this bead, om-yd1h, fixes). We seeded
633+
// exactly two rows into this otherwise-untouched grid, so 2 is the settled count. (Uses
634+
// the shared awaitRowCount, scoped to the Supplies delete-button rows.)
635+
await awaitRowCount(2, { sel: supplyRowSel })
596636
const rowsBefore = await supplyRows().count()
597637
ok('fixture: both QA supply rows are in the grid',
598638
rowsBefore === 2 && (await supplyItems()).includes(SUPPLY_GONE), `${rowsBefore} row(s)`)
@@ -647,7 +687,7 @@ try {
647687
await confirmDlg.waitFor({ timeout: 5000 })
648688
await confirmDlg.getByRole('button', { name: 'Delete', exact: true }).click()
649689
await confirmDlg.waitFor({ state: 'detached', timeout: 5000 })
650-
await awaitRowCount(rowsBefore - 1)
690+
await awaitRowCount(rowsBefore - 1, { sel: supplyRowSel, exact: true }) // count DROPS — must be exact
651691
const rowsAfter = await supplyRows().count()
652692
const itemsAfter = await supplyItems()
653693
ok('Confirm removes the row (grid)', rowsAfter === rowsBefore - 1,

0 commit comments

Comments
 (0)