commit 1e928a6e48cef0c9652dc80e477f9a72e170fc06
parent 607b04dfc664b90042702a0b493dfcf874511e77
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sun, 20 Sep 2026 18:33:02 -0400
e2e: match storage headers by text, and retry the clicks React has not wired yet
A column header's accessible name comes from its sort button's aria-label, as
channels-sort.spec.ts already found; and a click that lands before hydration
fires nothing at all, which is this suite's long-standing flake. Both actions
are idempotent, so the poll is safe.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
1 file changed, 39 insertions(+), 12 deletions(-)
diff --git a/editor/e2e/channels-storage-columns.spec.ts b/editor/e2e/channels-storage-columns.spec.ts
@@ -93,7 +93,11 @@ test("the Location and Size columns say which disk and how much", async ({
await expect(volumes.getByLabel("volume cold")).toContainText("free");
// FREE SPACE IS NOT A COLUMN. It is a fact about a disk; one read-out per
// volume, not one per row.
- await expect(page.getByRole("columnheader", { name: /free/i })).toHaveCount(0);
+ // (Matched on TEXT, not on the accessible name: each header's name comes from
+ // its sort button's aria-label, as channels-sort.spec.ts discovered.)
+ await expect(
+ page.getByRole("columnheader").filter({ hasText: /free/i }),
+ ).toHaveCount(0);
});
test("a channel with no report reads as unmeasured, never as empty", async ({
@@ -158,24 +162,47 @@ test("filter by volume, sort by size, and free up N GB", async ({
// /storage links here with it, so the "largest first" list it promised is the
// list that renders.
await page.goto("/channels?sort=size");
- await expect(
- page.getByRole("columnheader", { name: /^Size/ }),
- ).toHaveAttribute("aria-sort", "descending");
+ const sizeHeader = page
+ .getByRole("columnheader")
+ .filter({ hasText: /^Size/ });
+ await expect(sizeHeader).toHaveAttribute("aria-sort", "descending");
// And clicking the header flips it, like every other sortable column.
- await page.getByRole("button", { name: "sort by Size" }).click();
- await expect(
- page.getByRole("columnheader", { name: /^Size/ }),
- ).toHaveAttribute("aria-sort", "ascending");
+ await expect
+ .poll(
+ async () => {
+ await page
+ .getByRole("button", { name: "sort by Size" })
+ .click({ timeout: 5_000 })
+ .catch(() => {});
+ return sizeHeader.getAttribute("aria-sort");
+ },
+ { timeout: 30_000, intervals: [250, 500, 1_000, 1_000, 2_000] },
+ )
+ .toBe("ascending");
// --- free up N GB --------------------------------------------------------
await page.goto("/channels");
// Nothing ticked, so the deck is not there — which is why the control lives
// in the volume bar and not on the deck.
await expect(page.getByLabel("channel priority bulk")).toHaveCount(0);
- await page.getByLabel("free up target GB").fill("1");
- await page
- .getByLabel("select largest channels to free up")
- .click();
+ // CLICK UNTIL IT TOOK. Every control here is a React handler attached after
+ // the server HTML arrives; Playwright's actionability check is satisfied by
+ // the BUTTON, not by the listener, so a click landing in that window is
+ // silently dropped. The action is idempotent — the same selection is computed
+ // from the same rows — so retrying until the consequence appears is both safe
+ // and the honest way to wait for a listener the DOM cannot advertise.
+ const target = page.getByLabel("free up target GB");
+ const selectLargest = page.getByLabel("select largest channels to free up");
+ await expect
+ .poll(
+ async () => {
+ await target.fill("1").catch(() => {});
+ await selectLargest.click({ timeout: 5_000 }).catch(() => {});
+ return page.getByLabel(`select ${SLUG}`).isChecked();
+ },
+ { timeout: 30_000, intervals: [250, 500, 1_000, 1_000, 2_000] },
+ )
+ .toBe(true);
// It ticked the in-place channel and NOT the one already on the platter —
// moving that frees nothing on the disk being emptied.