From 299ac3fddf119334e9b7d27511137c4693bb745a Mon Sep 17 00:00:00 2001 From: ael-dev3 <183124839+ael-dev3@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:57:52 +0100 Subject: [PATCH] fix(test): make browser synchronization deterministic --- CHANGELOG.md | 3 +++ crates/allthing-web/src/pages/dau.rs | 8 +++++-- e2e/composer-attachments.fixture.spec.ts | 27 +++++++++++++++++++----- e2e/dau.fixture.spec.ts | 11 ++++++++++ e2e/wallet-shares.fixture.spec.ts | 8 +++++-- 5 files changed, 48 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c19652..c50e352c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ All notable changes to Allthing are recorded here. The format follows [Keep a Ch ### Fixed +- Explain that complete 24-hour activity totals require a gap-free window instead of promising a first count from its start time alone. - Count active accounts from checked, consecutive Hypersnap events with canonical block times carried across pages, declared event totals and both shards' coverage. The new verification epoch keeps old rows without certifying their totals. Full account and 32-byte signing-key identities prevent prefix collisions; incomplete attribution stays unknown, and a fenced atomic save rejects stale concurrent runs. Action totals are labelled as merge events because the same message can appear on both shards. - Recover one confirmed missing entry-loader request without reloading or changing drafts and sessions; persistent failures show an accessible Reload button even if early Home data arrives later. Healthy startup adds no recovery traffic. @@ -33,6 +34,8 @@ All notable changes to Allthing are recorded here. The format follows [Keep a Ch ### Development +- Make attachment handoff fixtures verify file-selection order under deliberately reversed upload completion, and wait for the current finite animations when a transition is canceled or replaced. + - Pause the search fixture's clock before navigation so an old real-time timer cannot move it backward; preserve the exact timer-cancellation and 250 ms debounce checks. - Keep the live front-page check's exact navigation contract in step with the Home and Library links; first-paint, layout and interaction limits remain unchanged. - Library Back checks compare with the card position at the actual click, after browser actionability scrolling, while retaining the two-pixel restoration limit and exact cached-page checks. diff --git a/crates/allthing-web/src/pages/dau.rs b/crates/allthing-web/src/pages/dau.rs index f1d38f2a..551ea514 100644 --- a/crates/allthing-web/src/pages/dau.rs +++ b/crates/allthing-web/src/pages/dau.rs @@ -301,11 +301,15 @@ fn Meter(counted: ActivityPayload, today: i64) -> impl IntoView { Some(to) => format!("Unique accounts active in the 24 hours to {}", moment(to)), None => "Unique accounts active in a rolling 24 hours".to_owned(), }; - // The count and how it compares, or when it comes: the first window - // ends 24 hours after counting began, on the hour. + // The count and how it compares, or what coverage it still needs. + // A start-time estimate cannot promise a complete window across gaps. let active = counted.rolling_to.and(counted.rolling_24h); let pending = match (counted.rolling_to, active) { (_, Some(_)) => None, + (_, None) if !counted.gaps.is_empty() => Some( + "A 24-hour count requires a whole window without recorded gaps. Partial totals remain lower bounds." + .to_owned(), + ), (Some(_), None) => Some(match counted.since.map(|since| (since + 86_400 + 3_599).div_euclid(3_600) * 3_600) { Some(at) => format!( "The first 24-hour count comes once the 24 hours to {}, {} have been read.", diff --git a/e2e/composer-attachments.fixture.spec.ts b/e2e/composer-attachments.fixture.spec.ts index b8e8e024..f0276c24 100644 --- a/e2e/composer-attachments.fixture.spec.ts +++ b/e2e/composer-attachments.fixture.spec.ts @@ -65,12 +65,18 @@ class Media { posts = 0; held = false; refuse: [number, string][] = []; - private waiting: (() => void)[] = []; + private waiting: { type: string; go: () => void }[] = []; constructor(public limits: object | null = LIMITS) {} release() { this.held = false; - this.waiting.splice(0).forEach((go) => go()); + this.waiting.splice(0).forEach(({ go }) => go()); + } + + releaseType(type: string) { + const index = this.waiting.findIndex((upload) => upload.type === type); + if (index < 0) throw new Error(`No held ${type} upload`); + this.waiting.splice(index, 1)[0].go(); } async serve(page: Page) { @@ -85,7 +91,7 @@ class Media { this.posts += 1; const body = request.postDataBuffer() ?? Buffer.alloc(0); const type = request.headers()["content-type"]; - if (this.held) await new Promise((go) => this.waiting.push(go)); + if (this.held) await new Promise((go) => this.waiting.push({ type, go })); const refused = this.refuse.shift(); // The page may have stopped it meanwhile. if (refused) return route.fulfill({ status: refused[0], json: { error: refused[1] } }).catch(() => {}); @@ -725,22 +731,33 @@ test("a photo's Exif, GPS position, XMP and IPTC are taken out before it's uploa test("where Allthing doesn't post, the draft goes to the Farcaster app with its files as embeds", async ({ page }) => { const media = new Media(); - await ready(page, media, { key: false }); + const calls = await ready(page, media, { key: false }); await page.getByRole("button", { name: "Write a cast" }).click(); const dialog = page.getByRole("dialog", { name: "New cast" }); await dialog.getByRole("textbox").fill("From my phone's camera roll"); media.held = true; await attach(page, dialog, [await drawn(page, "one.png", "image/png", 0), await drawn(page, "two.jpg", "image/jpeg", 120)]); + await expect.poll(() => media.posts).toBe(2); + await expect(dialog.getByRole("button", { name: "Uploading…" })).toBeDisabled(); + // Finish the second selected file first: embeds follow selection order, + // while the fixture's uploads are recorded in completion order. + media.releaseType("image/jpeg"); + await expect.poll(() => media.uploads.map((upload) => upload.type)).toEqual(["image/jpeg"]); + await expect(dialog.getByRole("progressbar")).toHaveCount(1); await expect(dialog.getByRole("button", { name: "Uploading…" })).toBeDisabled(); media.release(); const post = dialog.getByRole("button", { name: "Post in Farcaster" }); await expect(post).toBeEnabled(); + expect(media.uploads.map((upload) => upload.type)).toEqual(["image/jpeg", "image/png"]); await post.click(); const handoff = page.getByRole("dialog", { name: "Post in Farcaster" }); const href = (await handoff.getByRole("link", { name: /Open on farcaster\.xyz/ }).getAttribute("href"))!; const url = new URL(href); expect(url.searchParams.get("text")).toBe("From my phone's camera roll"); - expect(url.searchParams.getAll("embeds[]")).toEqual(media.uploads.map((u) => u.url)); + const of = (type: string) => media.uploads.find((upload) => upload.type === type)!; + expect(url.searchParams.getAll("embeds[]")).toEqual([of("image/png").url, of("image/jpeg").url]); + expect(calls.sent).toHaveLength(0); + expect(calls.scheduled).toHaveLength(0); }); test("a scheduled cast keeps its files, on /scheduled and when it's changed", async ({ page }) => { diff --git a/e2e/dau.fixture.spec.ts b/e2e/dau.fixture.spec.ts index 4f07bfa0..53225bf6 100644 --- a/e2e/dau.fixture.spec.ts +++ b/e2e/dau.fixture.spec.ts @@ -359,6 +359,17 @@ test.describe("with the Worker", () => { await expect(meter.locator(".dau-what")).toHaveText(`The first 24-hour count comes once the 24 hours to ${short(Math.floor(first / DAY))}, ${hh(first)} UTC have been read.`); await expect(meter.locator(".dau-change")).toHaveCount(0); await expect(page.locator(".dau-behind")).toHaveCount(0); + // Reaching the first possible end time is not sufficient if source + // coverage has a gap: do not promise a complete count at that time. + await serve(page, payload({ + rolling_24h: undefined, previous_24h: undefined, since: to - 3_600, + gaps: [[to - 1_800, to - 1_798]], + days: [days[29]], hourly: [], active_30d: undefined, apps_24h: undefined, + })); + await page.reload(); + await expect(meter.locator(".dau-big")).toHaveText("—"); + await expect(meter.locator(".dau-what")).toHaveText("A 24-hour count requires a whole window without recorded gaps. Partial totals remain lower bounds."); + await expect(meter.locator(".dau-change")).toHaveCount(0); }); const invalidProofs = [ diff --git a/e2e/wallet-shares.fixture.spec.ts b/e2e/wallet-shares.fixture.spec.ts index bb7dc689..d15749e9 100644 --- a/e2e/wallet-shares.fixture.spec.ts +++ b/e2e/wallet-shares.fixture.spec.ts @@ -112,9 +112,13 @@ const six = [ evm("zora", "Zora", 200, 2), ]; const shown = async (list: Locator) => (await list.locator("li:visible .pct").allTextContents()).reduce((sum, pct) => sum + parseInt(pct, 10), 0); -/** Every animation that ends has ended: the screenshot shows the page at rest. */ +/** Wait for the current finite animations, including replacements, to finish. + * CSS transitions can be canceled as a popover settles; their old `finished` + * promises reject even though the replacement still needs to run. Polling + * the live set waits for the page at rest without hiding an evaluation error. + */ const settled = (page: Page) => - page.evaluate(() => Promise.all(document.getAnimations().filter((a) => a.effect?.getComputedTiming().iterations !== Infinity).map((a) => a.finished))); + page.waitForFunction(() => document.getAnimations().every((a) => a.effect?.getComputedTiming().endTime === Infinity || a.playState === "finished")); for (const width of [1280, 390]) { test(`at ${width}, the shares shown add up to 100 on the profile and its card, and both say how they're counted`, async ({ browser }, testInfo) => {