From 001c25d34ddfbd7c902542d4ba2cece9f9ddab4f Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Thu, 20 Aug 2026 06:24:33 +0000 Subject: [PATCH] E2E: wait for the search to settle instead of sleeping 700ms A re-search settles in 0.5 to 2.0 seconds against the committed snapshot, and the staged-edit scenario allowed 700ms, so the nightly deploy has been red since 14 August. Every assertion on search output now polls, and the scenario waits for the mounted UI in place of its 900ms settle. The staged edit also went into the origin field rather than the destination. --- scripts/e2e.mjs | 61 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 43 insertions(+), 18 deletions(-) diff --git a/scripts/e2e.mjs b/scripts/e2e.mjs index bab3e00..4cb0e3f 100644 --- a/scripts/e2e.mjs +++ b/scripts/e2e.mjs @@ -126,7 +126,9 @@ async function scenario(name, url, body, opts = {}) { let ok = true, detail = ""; try { await page.goto(url, { waitUntil: "networkidle2", timeout: 45_000 }); - await sleep(900); // let the async dataset load + first render settle + // Wait for the mounted UI, never a fixed sleep: the dataset loads asynchronously + // and a busy machine pushes the first render past whatever the guess was. + await until(async () => (await $count(page, ".mode-tab.active")) > 0); await body(page); if (errors.length) throw new Fail(`page/same-origin errors: ${errors.join(" | ")}`); } catch (e) { @@ -144,6 +146,22 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // Helpers evaluated in-page. const $count = (page, sel) => page.$$eval(sel, (els) => els.length).catch(() => 0); const $text = (page, sel) => page.$eval(sel, (el) => el.textContent || "").catch(() => null); +// A search runs off the main thread and settles in 0.5-2.0s against the committed +// snapshot, so anything asserting on its output polls instead of sleeping a guess. +async function until(fn, timeout = 15_000) { + const deadline = Date.now() + timeout; + for (;;) { + const v = await fn(); + if (v) return v; + if (Date.now() >= deadline) return null; + await sleep(100); + } +} +const titleMatching = (page, re) => + until(async () => { + const t = (await $text(page, "#results-title")) || ""; + return re.test(t) ? t : null; + }); // The v2 UI tabs by trip type (data-trip: simple | return | multi | ideas); the // search *mode* (from/to/od/tour/best) is derived from the active trip plus which // station fields are filled, and still travels in the URL as ?mode=. @@ -185,8 +203,8 @@ await scenario( `${BASE}?mode=od&from=${enc(P)}&to=${enc(T)}&date=${DATE}`, async (page) => { assert((await activeTrip(page)) === "simple", "active tab should be 'simple'"); - const title = (await $text(page, "#results-title")) || ""; - assert(/paris/i.test(title) && /toulouse/i.test(title), `title wrong: "${title}"`); + const title = await titleMatching(page, /paris[\s\S]*toulouse/i); + assert(title, `title wrong: "${(await $text(page, "#results-title")) || ""}"`); const rs = await resultsState(page); assert(rs.children >= 1, "results panel is empty (no calendar/rows/empty-state)"); }, @@ -198,11 +216,12 @@ await scenario( "behaviour: a typed route edit stays staged until Search is clicked", `${BASE}?mode=od&from=${enc(P)}&to=${enc(T)}&date=${DATE}`, async (page) => { - const before = (await $text(page, "#results-title")) || ""; - assert(/toulouse/i.test(before), `precondition failed, title="${before}"`); - // Change the destination to Lyon WITHOUT searching. + const before = await titleMatching(page, /toulouse/i); + assert(before, `precondition failed, title="${(await $text(page, "#results-title")) || ""}"`); + // Change the destination to Lyon WITHOUT searching. `.od-fields` holds the origin + // then the destination; the other station inputs belong to the multi-city legs. await page.evaluate((val) => { - const inputs = document.querySelectorAll('.search-form .fields input[list="station-list"]'); + const inputs = document.querySelectorAll('.search-form .od-fields input[list="station-list"]'); const dest = inputs[1]; dest.value = val; dest.dispatchEvent(new Event("input", { bubbles: true })); @@ -214,9 +233,8 @@ await scenario( `title changed before Search (staged edit leaked): "${staged}"`); // Now click Search — the staged change applies. await page.click(".search-form .form-actions button.btn-primary"); - await sleep(700); - const after = (await $text(page, "#results-title")) || ""; - assert(/lyon/i.test(after), `title did not update after Search: "${after}"`); + const after = await titleMatching(page, /lyon/i); + assert(after, `title did not update after Search: "${(await $text(page, "#results-title")) || ""}"`); }, ); @@ -249,9 +267,10 @@ await scenario( `${BASE}?mode=tour&legs=${enc(`${P}>${L}@${DATE}~${L}>${P}@${DATE2}`)}&date=${DATE}`, async (page) => { assert((await activeTrip(page)) === "multi", "active tab should be 'multi'"); - const title = (await $text(page, "#results-title")) || ""; - assert(/multi/i.test(title), `multi-city title wrong: "${title}"`); - assert((await $count(page, ".mc-result")) >= 1, "no multi-city leg sections rendered"); + const title = await titleMatching(page, /multi/i); + assert(title, `multi-city title wrong: "${(await $text(page, "#results-title")) || ""}"`); + assert(await until(async () => (await $count(page, ".mc-result")) >= 1), + "no multi-city leg sections rendered"); const rs = await resultsState(page); assert(rs.children >= 1, "multi-city results panel empty"); }, @@ -263,10 +282,13 @@ await scenario( `${BASE}?mode=best&from=${enc(P)}&date=${DATE}`, async (page) => { assert((await activeTrip(page)) === "ideas", "active tab should be 'ideas'"); - const rs = await resultsState(page); // Ideas discovers EVERY destination from the origin — it must render a populated ranked // list, not a blank/empty state (regression: the page rendered nothing). - assert(rs.children >= 3 && !rs.hasEmpty, "ideas did not render a populated destination list"); + const rs = await until(async () => { + const s = await resultsState(page); + return s.children >= 3 && !s.hasEmpty ? s : null; + }); + assert(rs, "ideas did not render a populated destination list"); assert( (await $count(page, ".results [data-station], .results [class*='-row']")) > 0, "ideas rendered no destination rows", @@ -336,12 +358,15 @@ await scenario( // Locale of the headless browser can be en or fr; accept either 1-night label. assert(["1 night", "1 nuit"].includes(stepperText), `nights stepper should read one night (got '${stepperText}')`); // Two-leg accordion (Aller / Retour), each a collapsible .mc-result section. - assert((await $count(page, ".mc-result")) === 2, "expected a two-leg accordion (outbound + return)"); + assert(await until(async () => (await $count(page, ".mc-result")) === 2), + "expected a two-leg accordion (outbound + return)"); // Both possible-days calendars (outbound + return) are collapse-by-click: for a FIXED // 1-night stay each is collapsed behind its own "Départ / Retour : … · Changer" toggle, // so at least two calendar grids and two toggles exist in the DOM. - assert((await $count(page, ".cal-grid")) >= 2, "expected the outbound + return availability calendars"); - assert((await $count(page, ".cal-toggle")) >= 2, "expected both collapsed-calendar toggles"); + assert(await until(async () => (await $count(page, ".cal-grid")) >= 2), + "expected the outbound + return availability calendars"); + assert(await until(async () => (await $count(page, ".cal-toggle")) >= 2), + "expected both collapsed-calendar toggles"); }, );