diff --git a/CHANGELOG.md b/CHANGELOG.md index ae8797f7..00e2268c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ All notable changes to Allthing are recorded here. The format follows [Keep a Ch ### Fixed +- A hover card dismissed with Escape cannot reopen when the pointer re-enters during its closing animation. - A data center with no default 24-hour Home copy, or an expired retained one, can use a fresh public copy shared by another data center, preserving its original age and current time bounds, instead of waiting for the full upstream build. Missing or invalid copies retain the normal fallback, ordinary background refreshes still build normally, and other ranges and private feeds are unchanged. - CI rejects browser tests that pass only on retry, so a flaky run cannot appear as a clean release check. - Choosing an emoji group while the picker is still drawing reaches its heading on the first click, including a group at the temporary bottom of the list. @@ -27,17 +28,25 @@ All notable changes to Allthing are recorded here. The format follows [Keep a Ch - Following prefetches its own feed when changing sort or range, stops automatic paging at the time limit, and resumes from that cursor for a wider range. A fully seen or filtered feed offers Load more posts after ten payloads rather than reading indefinitely. - Changing accounts replaces the feed and ranking snapshot, and late answers from the previous account cannot refill its cache. Presence no longer counts an anonymous and signed-in visit twice; hiding or signing out also removes the public name from cached lists. - Messages connects under the existing Content Security Policy without permitting JavaScript evaluation. Pressed conversation rows keep their previews and timestamps readable. -- Notifications coalesce refreshes from opening the page and regaining focus. Marking the list read no longer queues a duplicate request that could start after the tab is hidden. +- Notifications share the bell's initial read even when it finishes before the page's code arrives. A failure remains visible for Try again, and a fresh answer is reused. Opening the page, regaining focus and marking the list read no longer queue duplicate reads behind an in-flight refresh or after the tab is hidden. - A wrapped reply's app link clears the collapse button's full tap area. ### Development +- Pin the current Miniflare development dependency's Undici to 7.30.1, fixing its [EventSource memory](https://github.com/advisories/GHSA-4qv8-2p9j-2685), [integrity-check](https://github.com/advisories/GHSA-f72v-6w45-q472) and [content-encoding](https://github.com/advisories/GHSA-xjj6-f33q-929c) advisories. The version-scoped override leaves Wrangler, Miniflare and workerd unchanged; Wrangler 4.152.0 also still pins an affected version. + +- Smoothness checks retain bounded, sanitized raw input, long-task, animation-frame and frame diagnostics after each profile closes, including on failure. Metric inputs, strict checks and timing limits are unchanged. + +- Live Worker checks pace API traffic between tests, within the existing per-IP limit, and retain bounded request counts and sanitized failed-response diagnostics. Interaction timings, assertions and retry counts are unchanged. + - Back-navigation checks wait for sampled Home and post frames before returning, preserving their exact retained-card, restored-height, position and request assertions. - CI runs functional browser fixtures with two workers, followed by smoothness measurements alone with one. Both phases run and keep separate reports even after a failure, under the same checks and timing limits. - Video keyboard checks verify a held Space press on the original pointer-focused control before release, retaining bounded input and media diagnostics on failure. - Cold-load performance checks retain sanitized per-sample LCP, network, cache and long-task diagnostics, including on failure, in a seven-day Actions artifact. Their timing limits, cold contexts and sample count are unchanged. - Browser regressions use past timestamps relative to the page’s own clock, wait for restored seen rows and the latest presence-cache expiry, and preserve their ordering, privacy, navigation and timing checks, including a deliberately delayed count refresh. +- Browser checks recover read posts through Show seen posts, distinguish media uploads from analytics XHRs, control typeahead's input intervals and settle initial fixture resources before measuring reply-sheet shifts. Live checks observe pagination retries, available wallet data and the deployed inline-fetch/CSP contract without changing their timing limits. +- The roadmap lists the participating and Messages features already delivered, and the architecture's static-test command matches CI. - Startup checks distinguish the main WebAssembly module from idle-loaded parts, and range navigation waits for the actual New feed response. - Local macOS builds find worker-build's cached WebAssembly optimizer. SDK wire-format and strict-CSP browser checks cover Messages; feed-ranking, reply-composer and smoothness fixtures exercise the updated UI without changing their budgets. - The attestation-key helper replaces the complete Rust key array, including multiline values, so a generated key compiles. diff --git a/crates/allthing-web/src/components/notifications.rs b/crates/allthing-web/src/components/notifications.rs index 06decb5d..5434a360 100644 --- a/crates/allthing-web/src/components/notifications.rs +++ b/crates/allthing-web/src/components/notifications.rs @@ -119,9 +119,10 @@ impl Notifications { self.whose() == Some(fid) } - /// The first page's times, as last read (tracked). + /// The first page's times once its latest refresh succeeded (tracked). + /// Old times must not start a page read behind a refresh that can fail. pub fn times(self) -> Option> { - self.0.with(|state| state.times.clone()) + self.0.with(|state| if state.loading || state.failed.is_some() { None } else { state.times.clone() }) } /// Why the last read failed, if it did (tracked). @@ -135,6 +136,18 @@ impl Notifications { self.0.with_untracked(|state| (state.whose == Some(fid)).then_some(state.seen)).unwrap_or_else(|| seen_of(fid)) } + /// Opening the page shares the bell's initial read, including a failure + /// that arrived before the page's code. Only Try again or a later refresh + /// should retry that failure; a fresh answer is already ready to reuse. + pub fn opened(self) { + if self.0.with_untracked(|state| state.loading || state.failed.is_some()) + || api::peek::(NOTIFICATIONS_PATH).is_some() + { + return; + } + self.refresh(); + } + /// Read the first page again for the reader signed in, unless a read /// within a minute can be reused or is on its way (`api::get`). pub fn refresh(self) { diff --git a/crates/allthing-web/src/components/popover.rs b/crates/allthing-web/src/components/popover.rs index 0cf61a55..6965e64f 100644 --- a/crates/allthing-web/src/components/popover.rs +++ b/crates/allthing-web/src/components/popover.rs @@ -296,10 +296,13 @@ pub fn Popover( let enter = move |_: ev::MouseEvent| { warm(); if hovering() { - if state.get_untracked() == State::Open { - cancel(); - } else { - schedule(open_delay, Box::new(open)); + match state.get_untracked() { + State::Open => cancel(), + State::Closed => schedule(open_delay, Box::new(open)), + // Escape has dismissed this panel. A pointer entering while + // it fades out must not arm another open: a profile's dwell + // is shorter than its closing animation. + State::Closing => {} } } }; diff --git a/crates/allthing-web/src/pages/notifications.rs b/crates/allthing-web/src/pages/notifications.rs index 9910ac5f..9f5b00ca 100644 --- a/crates/allthing-web/src/pages/notifications.rs +++ b/crates/allthing-web/src/pages/notifications.rs @@ -224,7 +224,7 @@ pub mod with_worker { let more = RwSignal::new(Vec::>::new()); let read_failed = RwSignal::new(None::); // Marking the page seen changes the bell's state too, but not its - // times. Only a different set of times should ask for the page: + // times. Only a settled refresh should ask for the page: // an async read queued by mark_seen could otherwise run later, // after its cached answer expires or while the tab is hidden. let times = Memo::new(move |_| notifications.times()); @@ -251,7 +251,7 @@ pub mod with_worker { notifications.mark_seen(fid, page.newest); } }); - notifications.refresh(); + notifications.opened(); let pages = Memo::new(move |_| first.get().into_iter().chain(more.get()).collect::>()); // Whether the first page is in: a newer copy of it updates the rows // in place rather than drawing the list anew. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e57f2227..faa3c186 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -510,7 +510,7 @@ The header's small "● 12" says how many people are reading Allthing now. Hover - **Lints**: `scripts/check.sh` runs rustfmt and Clippy with `-D warnings` for the native and wasm32 targets, and the `cargo deny` license audit. - **Tooling**: `npm run test:tools` checks the static test server with temporary fixture directories (malformed paths, root boundaries, redirects and deep-link fallback), and the production deployment wait against a local HTTP server (revision matching, transient failures and bounded timeouts). CI runs it before the browser fixtures. - **Browser regressions**: `npx playwright test '\.fixture\.spec\.ts$'` runs deterministic browser tests against the local build, with API and media responses supplied by fixtures. CI runs these on pull requests and pushes to `main`; new deterministic suites should use the `.fixture.spec.ts` suffix. They exercise the code under review without depending on live data. Failure reports are uploaded for 7 days. -- **Static payloads**: CI also builds the `/Allthing` copy and runs `E2E_PAGES=1 npx playwright test e2e/static-body-limit.fixture.spec.ts e2e/static-metadata.fixture.spec.ts e2e/static-navigation.fixture.spec.ts e2e/preloaded-replies.fixture.spec.ts e2e/poll.fixture.spec.ts`. These fixtures exercise the browser Host's capped HTML streams, cleanup after read failures, complete uncapped JSON responses, and linked-page metadata filtering with inert fallback text and actual frame declarations, check that what the app opens itself (a cast's card, a search, a replies count, the time range) stays under `/Allthing`, that a replies preview built in the browser, by the code a popover batch builds replies with, leaves out spam and mini apps, and that the copy has no poll. +- **Static payloads**: CI also builds the `/Allthing` copy and runs `E2E_PAGES=1 npx playwright test e2e/static-body-limit.fixture.spec.ts e2e/static-metadata.fixture.spec.ts e2e/static-navigation.fixture.spec.ts e2e/static-follows.fixture.spec.ts e2e/preloaded-replies.fixture.spec.ts e2e/poll.fixture.spec.ts`. These fixtures exercise the browser Host's capped HTML streams, cleanup after read failures, complete uncapped JSON responses, and linked-page metadata filtering with inert fallback text and actual frame declarations, check that what the app opens itself (a cast's card, a search, a replies count, the time range) stays under `/Allthing`, that follows navigation works under that prefix, that a replies preview built in the browser, by the code a popover batch builds replies with, leaves out spam and mini apps, and that the copy has no poll. - **Performance**: `e2e/perf.spec.ts` checks the budget in [PERFORMANCE.md](PERFORMANCE.md); CI runs it against production after each push to `main`. - **Writes land, or are taken back**: `e2e/verified-writes.fixture.spec.ts`, at 1280 and 390 px, likes, recasts, quotes and their undoing with the reader's real WebCrypto key against a stand-in node (`e2e/helpers/node.ts`, which every spec that writes uses). The stand-in checks each message as Snapchain would (BLAKE3 of its data is its hash, its Ed25519 signature is by the key the reader approved, mainnet, the reader's fid, the time) and answers the read-backs from what it merged. It covers a write merged and shown; taken but never shown, which is undone after 15 s, with Retry; refused, with the node's reason; an unknown key, which offers the approval; a busy node, then success; a lost answer, the same message again and a duplicate, counted once; each undoing; and a quote shown at once, posted once shown, or gone with its draft back. - **A reply in its thread**: `e2e/reply-in-thread.fixture.spec.ts`, at 1280 and 390 px: a reply at the top of its post's replies the moment Reply is pressed, before the node answers, as posting (faded, its actions inert), then posted, with its time linking to it, and once when the page lists it; a reply to a reply under it; the count on the post and in the feed; refused, or not shown in 15 s, gone with the count back and the composer open again with the draft and why, and Retry sending the same message. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c05b2e83..38c8b9e1 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,7 +1,7 @@ # Roadmap -Milestones, not dates. Releases use SemVer ([RELEASING.md](RELEASING.md)); details per release are in the [changelog](../CHANGELOG.md). Allthing reads; the owner has since scoped a second phase, one feature at a time: Sign In With Farcaster by QR code first, then a post and reply composer. +Milestones, not dates. Releases use SemVer ([RELEASING.md](RELEASING.md)); details per release are in the [changelog](../CHANGELOG.md). Allthing supports reading, participating through a reader-approved Farcaster key, and its own Messages. ## Done @@ -27,20 +27,28 @@ Milestones, not dates. Releases use SemVer ([RELEASING.md](RELEASING.md)); detai - [x] mini app filtering; - [x] a simpler interface; - [x] a release process. -- **Since 0.3.0, unreleased** ([changelog](../CHANGELOG.md#unreleased)): +- **Reading improvements since 0.3.0** ([changelog](../CHANGELOG.md)): - [x] a Reddit-style time filter on the front page; - [x] each cast's whole text in feeds, and media at its own shape; - [x] video sound that stays on once the reader turns it on; - [x] spam, and mini apps on their own domains, left out; - [x] a plainer interface, without Neynar scores, the Channels sidebar or "Updated N min ago"; - [x] browser regression tests on every pull request. +- **Participating and Messages**: + - [x] one Farcaster approval to sign in and write, with the key kept in the browser; + - [x] casts, replies, quotes, likes, recasts, follows and deletes; + - [x] a composer with attachments, GIFs, stickers, emoji and scheduled casts; + - [x] notifications and verified Farcaster accounts in Messages, with end-to-end encrypted DMs. +- **0.5.1 release candidate** ([changelog](../CHANGELOG.md#051---2026-10-23)): + - [x] For you and Following tabs, each with hot, new, top and every time range; + - [x] browser-local seen-post history with Show seen posts and reset controls; + - [x] mini app controls in Settings and a responsive Home/online-count header; + - [x] fresh short-range casts and a shared public Home snapshot for cold data centers. ## Next -- **Phase 2: participating.** Sign In With Farcaster by QR code through the Farcaster app ([ARCHITECTURE.md](ARCHITECTURE.md#sign-in)); next, a post and reply composer with a GIF picker (search and trending, from a free GIF API). Posting needs a signer the account approves in the Farcaster app, and that scope comes with the composer. - - **A second backend.** Read the same casts from a Snapchain node (or a second Hypersnap operator). Show which backends returned a cast, and let readers pick the source. Reconcile by message hash, not by text. -- **Smaller app.** The WebAssembly module is about 410 KB compressed, mostly the Leptos runtime and `serde_json`. Candidates: a leaner JSON decoder for payloads, splitting rarely used views, and trimming web-sys features. +- **Smaller app.** Rarely used views already load as separate WebAssembly parts. Further candidates include a leaner JSON decoder, more selective splitting and trimming web-sys features; measurements belong in [PERFORMANCE.md](PERFORMANCE.md). - **Solana activity.** Find a keyless source of Solana address history that allows browser or Worker requests, or document a key-based option that keeps the key out of the repository. - **Reading controls kept in the browser.** Local mutes and filters, saved posts, and remembered collapse state. - **Ingress provenance.** Prototype phase 2 of [PROVENANCE.md](PROVENANCE.md), signed ingress receipts, against a Hypersnap fork, and show receipts when nodes publish them. diff --git a/e2e/allthing.spec.ts b/e2e/allthing.spec.ts index 3bdbf510..8ed082fe 100644 --- a/e2e/allthing.spec.ts +++ b/e2e/allthing.spec.ts @@ -2,11 +2,17 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { expect, test, type Locator, type Page, type Route } from "@playwright/test"; +import { appCame } from "./helpers/app"; +import { livePacing } from "./helpers/live-pacing"; /** Waits until the images on screen have loaded, so nothing shifts under the pointer. */ async function settleImages(page: Page) { await page.evaluate(async () => { - const pending = [...document.images].filter((img) => !img.complete); + await document.fonts.ready; + const pending = [...document.images].filter((img) => { + const box = img.getBoundingClientRect(); + return !img.complete && box.width > 0 && box.bottom > 0 && box.top < innerHeight; + }); await Promise.race([ Promise.all( pending.map( @@ -55,6 +61,7 @@ async function expectClearOfHeader(page: Page, panel: Locator) { const SHOTS = process.env.E2E_SCREENSHOT_DIR ?? "docs/screenshots"; const LIVE = Boolean(process.env.E2E_BASE_URL); const PAGES = Boolean(process.env.E2E_PAGES); +livePacing(LIVE && !PAGES ? process.env.E2E_BASE_URL : undefined); // The static copy is served under a sub-path, like https://ael-dev3.github.io/Allthing/. const BASE_PATH = process.env.E2E_BASE_PATH ?? (PAGES ? "/Allthing" : ""); const at = (path: string) => BASE_PATH + path; @@ -125,8 +132,10 @@ test.describe("front page", () => { await expect(card.locator(".stat-likes .n")).toHaveText(/^\d[\d,.km]*$/); await expect(card.locator(".stat-recasts")).toContainText(/recasts?/); await expect(card.locator(".stat-replies")).toContainText(/repl(y|ies)/); - await expect(page.locator('[aria-label*="downvote" i], .downvote, .arrow.down')).toHaveCount(0); - await expect(page.getByRole("button", { name: /vote/i })).toHaveCount(0); + await expect(page.locator('.engagement [aria-label*="downvote" i], .engagement .downvote, .engagement .arrow.down')).toHaveCount(0); + // Public authors and image descriptions may themselves contain "vote". + // This checks the engagement controls, not those user-provided names. + await expect(page.locator(".engagement").getByRole("button", { name: /^(?:upvote|downvote|vote)\b/i })).toHaveCount(0); // Who, when, where, and via which app; no scores beside names. await expect(card.locator(".user-chip .avatar").first()).toBeVisible(); @@ -209,11 +218,17 @@ test.describe("front page", () => { }); test("a feed page that fails to load is tried again", async ({ page }) => { - // The second page fails once, as a Worker error would. + // A first page now mixes trending and newest casts, so it can already + // contain more than 100. Observe the failed request and its recovered + // row directly instead of treating a card count as proof of a retry. let failed = 0; - await page.route(/\/api\/v1\/home\?offset=100$/, async (route: Route) => { + const recovered = `0x${"fe".repeat(20)}`; + await page.route((url) => url.pathname === "/api/v1/home" && url.searchParams.get("offset") === "100", async (route: Route) => { if (failed++ === 0) return route.fulfill({ status: 500, json: { error: "upstream failed" } }); - await route.fallback(); + return route.fulfill({ json: { + pool: [{ hash: recovered, author: { fid: 1234, username: "retry-fixture" }, text: "The recovered page", ts: Math.floor(Date.now() / 1000) - 1 }], + notes: {}, meta: { backend: "hypersnap", fetched_at: Date.now() }, + } }); }); await page.goto(at("/")); await expect(firstCard(page)).toBeVisible(); @@ -221,12 +236,15 @@ test.describe("front page", () => { .poll( async () => { await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); - return page.locator(".cast-card").count(); + return failed; }, { timeout: 30_000, intervals: [600] }, ) - .toBeGreaterThan(100); - expect(failed).toBeGreaterThanOrEqual(2); + .toBeGreaterThanOrEqual(2); + await expect(page.locator(`.cast-list > .cast-card[data-hash="${recovered}"]`)).toBeVisible(); + const hashes = await page.locator(".cast-list > .cast-card[data-hash]").evaluateAll((cards) => cards.map((card) => card.getAttribute("data-hash"))); + expect(hashes.filter((hash) => hash === recovered)).toHaveLength(1); + expect(new Set(hashes).size).toBe(hashes.length); }); test("each page view costs one API call", async ({ page }) => { @@ -577,6 +595,10 @@ test.describe("profiles", () => { await expect(profile.getByText(/Neynar/)).toHaveCount(0); // Only "Voted yes", "Voted no" or "Didn't vote", never the old wordings. await expect(page.getByText(/Voted to (stay|leave)|Stay vote revoked|Did not vote|checking…/)).toHaveCount(0); + const vitalikWallets = PAGES ? null : page.waitForResponse((response) => { + const url = new URL(response.url()); + return url.pathname === "/api/v1/wallets" && url.searchParams.get("fid") === "5650"; + }); await page.goto(at("/u/vitalik.eth")); await expect(page.locator(".profile .vote-fact .vote")).toHaveText("Didn't vote"); // Registered before the ID registry's move to OP Mainnet: joined when @@ -588,7 +610,32 @@ test.describe("profiles", () => { const wallets = page.locator(".profile .profile-wallets"); await expect(wallets.locator(".share-bar")).toBeVisible(); await expect(wallets.locator(".share-list li").first()).toContainText(/%/); - await expect(wallets.locator(".share-list")).toContainText("Base"); + // Public RPCs can be unavailable, and the UI deliberately labels partial + // data. Check the chains the page actually received, not a fixed chain. + // The static copy reads RPCs itself; its deterministic share fixtures + // verify the complete/partial mapping without a Worker response. + if (!PAGES) { + const response = await vitalikWallets!; + expect(response.status()).toBe(200); + const activity = await response.json(); + expect(activity.fid).toBe(5650); + const measured = activity.chains.filter((chain: { error?: string; count: number }) => !chain.error && chain.count > 0); + const label = (await wallets.locator(".share-bar").getAttribute("aria-label"))!; + const shares = label.split(", ").map((entry) => { + const match = /^(.+) (\d+)%$/.exec(entry); + expect(match).not.toBeNull(); + return { name: match![1], percent: Number(match![2]) }; + }); + expect(shares.length).toBeGreaterThan(0); + expect(shares.reduce((total, share) => total + share.percent, 0)).toBe(100); + for (const share of shares) { + expect(measured.map((chain: { name: string }) => chain.name)).toContain(share.name); + expect(share.percent).toBeGreaterThan(0); + } + await expect(wallets.locator(".share-list")).toContainText(shares[0].name); + const partial = activity.chains.some((chain: { error?: string }) => Boolean(chain.error)); + await expect(wallets.getByRole("button", { name: "partial: why", exact: true })).toHaveCount(partial ? 1 : 0); + } await expect(page.getByText(/How this is measured|Solana: Not measured|No activity on|Posts via|Verified addresses/)).toHaveCount(0); await expect(page.locator(".side")).toHaveCount(0); await expect(firstCard(page)).toBeVisible(); @@ -664,6 +711,7 @@ test.describe("threads and navigation", () => { test("the whole card opens the post, but selecting text or clicking links does not", async ({ page }) => { await page.goto(at("/")); + await appCame(page); const card = textCards(page).first(); await expect(card).toBeVisible(); const hash = await card.getAttribute("data-hash"); @@ -793,6 +841,9 @@ test.describe("threads and navigation", () => { await page.goto(at(`/p/${NESTED_THREAD}`)); const foot = page.locator(".post .engagement"); await expect(foot).toBeVisible(); + // Scroll before settling: that exposes the lazy media around the actual + // hover target, rather than only the page's initial viewport. + await foot.locator(".stat-likes").scrollIntoViewIfNeeded(); await settleImages(page); await foot.locator(".stat-likes").hover(); const likes = page.getByRole("dialog", { name: "Liked by" }); @@ -1102,8 +1153,27 @@ test.describe("threads and navigation", () => { page.on("console", (m) => /Content Security Policy/.test(m.text()) && violations.push(m.text())); const calls: string[] = []; page.on("request", (r) => r.url().includes("/api/v1/") && calls.push(new URL(r.url()).pathname + new URL(r.url()).search)); + // This is an inline-fetch/CSP/normalization contract. Keep the deployed + // HTML and headers, with a fixed payload so an upstream 503 cannot + // replace the content whose single request is being checked. The live + // feed, profile and channel cases above still exercise real data. + const cast = { hash: GALLERY_CAST, author: { fid: 1234, username: "inline-fixture", display_name: "Inline fixture" }, text: "The inline response", ts: Math.floor(Date.now() / 1000) - 1 }; + // Fill a normal first page: a one-row fixture immediately reaches the + // infinite-scroll sentinel and legitimately requests the next page. + const pool = Array.from({ length: 100 }, (_, index) => ({ + ...cast, hash: `0x${(index + 1).toString(16).padStart(40, "0")}`, + text: `${cast.text} ${index + 1}`, ts: cast.ts - index, + })); + const listing = { pool, notes: {}, meta: { backend: "hypersnap", fetched_at: Date.now() } }; + const payload = page_ === "post" ? { thread: { cast, children: [] }, notes: {}, meta: listing.meta } + : page_ === "profile" ? { ...listing, user: { fid: 1234, username: "dwr", display_name: "Inline profile" } } + : page_ === "channel" ? { ...listing, channel: { id: "farcaster", name: "Farcaster", parent_url: "https://farcaster.xyz/~/channel/farcaster" } } + : listing; + await page.route("**/api/v1/**", (route) => route.fulfill({ status: 404, json: { error: "Fixture endpoint" } })); + await page.route((url) => url.pathname + url.search === api, (route) => route.fulfill({ json: payload })); await page.goto(at(path)); await expect(page.locator(shown).first()).toBeVisible(); + await appCame(page); // The inline script ran (CSP allowed it) and the app took its response: // the page's one data request is the one the script made. expect(await page.evaluate(() => "__allthingEarly" in window)).toBe(false); diff --git a/e2e/cast-compose.fixture.spec.ts b/e2e/cast-compose.fixture.spec.ts index 012a8d96..358a4224 100644 --- a/e2e/cast-compose.fixture.spec.ts +++ b/e2e/cast-compose.fixture.spec.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import { expect, test, type Browser, type Page, type Route, type TestInfo } from "@playwright/test"; +import { appCame } from "./helpers/app"; import { expectDraft } from "./helpers/composer"; import { approvedKey, cast as castOf, signIn, type Envelope } from "./helpers/farcaster"; import { standIn, type Answer } from "./helpers/node"; @@ -108,7 +109,14 @@ async function ready(page: Page, path = "/") { await expect(page.locator(".cast-card:not(.skeleton), .post").first()).toBeVisible(); await approvedKey(page, me.fid, 3_600_000); await page.reload(); - await expect(page.locator(".cast-card:not(.skeleton), .post").first()).toBeVisible(); + await appCame(page); + await expect.poll(async () => { + // Approving the key can take long enough to read the fixture's first + // screen. Restore it through the real control on this new generation. + const show = page.getByRole("button", { name: "Show seen posts", exact: true }); + if (await show.isVisible()) await show.click(); + return page.locator(".cast-card:not(.skeleton), .post").first().isVisible(); + }).toBe(true); return calls; } diff --git a/e2e/composer-attachments.fixture.spec.ts b/e2e/composer-attachments.fixture.spec.ts index 5bf88cd1..56e03043 100644 --- a/e2e/composer-attachments.fixture.spec.ts +++ b/e2e/composer-attachments.fixture.spec.ts @@ -153,16 +153,39 @@ async function watchUploads(page: Page) { await page.addInitScript(() => { const w = window as unknown as { uploads: { xhr: XMLHttpRequest; aborted: boolean }[] }; w.uploads = []; + const media = new WeakSet(); + const open = XMLHttpRequest.prototype.open; + XMLHttpRequest.prototype.open = function (this: XMLHttpRequest, ...args: [string, string | URL, ...unknown[]]) { + const url = new URL(args[1], location.href); + if (args[0].toUpperCase() === "POST" && url.origin === location.origin && url.pathname === "/api/v1/media") media.add(this); + else media.delete(this); + return Reflect.apply(open, this, args); + } as typeof open; const send = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.send = function (this: XMLHttpRequest, body?: Document | XMLHttpRequestBodyInit | null) { - const entry = { xhr: this, aborted: false }; - this.addEventListener("abort", () => (entry.aborted = true)); - w.uploads.push(entry); + if (media.has(this)) { + const entry = { xhr: this, aborted: false }; + this.addEventListener("abort", () => (entry.aborted = true)); + w.uploads.push(entry); + } return send.call(this, body); }; }); } +/** Analytics and other XHRs must not take an upload's progress/abort index. */ +async function unrelatedXhr(page: Page) { + const observed = await page.evaluate(() => new Promise<{ status: number; uploads: number }>((resolve, reject) => { + const request = new XMLHttpRequest(); + request.onload = () => resolve({ status: request.status, uploads: (window as unknown as { uploads: unknown[] }).uploads.length }); + request.onerror = () => reject(new Error("The unrelated fixture XHR failed")); + request.open("POST", "/api/v1/unrelated-xhr"); + request.send("not an upload"); + })); + // The fixture's catch-all answers it; the real upload remains index zero. + expect(observed).toEqual({ status: 404, uploads: 0 }); +} + /** Upload `n`'s progress, as the browser reports it: `loaded` bytes of `total`. */ const progress = (page: Page, n: number, loaded: number, total: number) => page.evaluate( @@ -381,6 +404,7 @@ const segment = (marker: number, payload: Buffer) => { test("at 1280, files attach from the button and a paste, show their uploads' progress, and go out as the cast's embeds", async ({ page }, info) => { const media = new Media(); const calls = await ready(page, media); + await unrelatedXhr(page); media.held = true; await page.getByRole("button", { name: "Write a cast" }).click(); const dialog = page.getByRole("dialog", { name: "New cast" }); @@ -607,6 +631,7 @@ test("files dropped on the composer attach, with a hint while they're over it; a test("Remove stops an upload on its way, and lets its picture go", async ({ page }) => { const media = new Media(); await ready(page, media); + await unrelatedXhr(page); media.held = true; const failed: string[] = []; page.on("requestfailed", (request) => { diff --git a/e2e/feed-session-ranges.fixture.spec.ts b/e2e/feed-session-ranges.fixture.spec.ts index de91c4c6..00ec5edf 100644 --- a/e2e/feed-session-ranges.fixture.spec.ts +++ b/e2e/feed-session-ranges.fixture.spec.ts @@ -58,7 +58,13 @@ for (const width of [1280, 390]) { await expect(selector).toBeVisible(); await expect(selector).toHaveValue(range); const expected = pool.filter((c) => c.ts <= now && now - c.ts < span).map((c) => c.text); - await expect.poll(async () => (await order(page)).toSorted()).toEqual(expected.toSorted()); + await expect.poll(async () => { + // Earlier ranges can genuinely be read while their page loads. + // Use this generation's control before checking exact membership. + const show = page.getByRole("button", { name: "Show seen posts", exact: true }); + if (await show.isVisible()) await show.click(); + return (await order(page)).toSorted(); + }).toEqual(expected.toSorted()); if (sort === "new") expect(await order(page)).toEqual(expected); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); } diff --git a/e2e/helpers/live-pacing.ts b/e2e/helpers/live-pacing.ts new file mode 100644 index 00000000..a5d9db6c --- /dev/null +++ b/e2e/helpers/live-pacing.ts @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import { setTimeout as pause } from "node:timers/promises"; +import { test, type APIRequestContext, type APIResponse, type BrowserContext, type Request, type Response } from "@playwright/test"; +import { endpoint, errorCode, RETAINED, TEST_HEADROOM, WINDOW_MS, quietFor } from "./live-request-window.mjs"; + +type Attempt = { method: string; path: string; at: number; status?: number; error_code?: string; retry_after?: number; cache?: string }; +type Answer = { attempt: Attempt; response: Response | APIResponse }; +const recent: number[] = []; + +/** Live Worker tests run alone with one worker. Pace between assertions, + * never in an interaction's timing window. Routed attempts are counted + * conservatively too. No answer is retried or accepted by this helper. */ +export function livePacing(baseURL: string | undefined) { + if (!baseURL) return; + const origin = new URL(baseURL).origin; + let attempts: Attempt[] = []; + let answers: Answer[] = []; + let restore: (() => void)[] = []; + + test.beforeAll(async () => { + // Also clears the previous worker's window after an unexpected failure. + await pause(WINDOW_MS + 1); + recent.length = 0; + }); + test.beforeEach(async ({ browser, context, request }) => { + attempts = []; answers = []; restore = []; + const contexts = new WeakSet(); + const clients = new WeakSet(); + const record = (url: string, method: string) => { + const path = endpoint(url, origin); + if (!path) return null; + const at = performance.now(); + recent.push(at); + const attempt = { method, path, at }; + attempts.push(attempt); + return attempt; + }; + const api = (client: APIRequestContext) => { + if (clients.has(client)) return; + clients.add(client); + const fetch = client.fetch; + client.fetch = async function (...args: Parameters) { + const input = args[0]; + const attempt = record(typeof input === "string" ? input : input.url(), (args[1]?.method ?? (typeof input === "string" ? "GET" : input.method())).toUpperCase()); + try { + const response = await Reflect.apply(fetch, this, args); + if (attempt) answers.push({ attempt, response }); + return response; + } catch (error) { + if (attempt) attempt.error_code = "network_failed"; + throw error; + } + }; + restore.push(() => { client.fetch = fetch; }); + }; + const watch = (context: BrowserContext) => { + if (contexts.has(context)) return; + contexts.add(context); + api(context.request); + const requests = new WeakMap(); + const sent = (request: Request) => { const attempt = record(request.url(), request.method()); if (attempt) requests.set(request, attempt); }; + const answered = (response: Response) => { const attempt = requests.get(response.request()); if (attempt) answers.push({ attempt, response }); }; + const failed = (request: Request) => { const attempt = requests.get(request); if (attempt) attempt.error_code = "network_failed"; }; + context.on("request", sent); context.on("response", answered); context.on("requestfailed", failed); + restore.push(() => { context.off("request", sent); context.off("response", answered); context.off("requestfailed", failed); }); + }; + api(request); watch(context); + const newContext = browser.newContext; + browser.newContext = async function (...args: Parameters) { + const context = await Reflect.apply(newContext, this, args); + watch(context); + return context; + }; + restore.push(() => { browser.newContext = newContext; }); + }); + + test.afterEach(async ({}, info) => { + const started = performance.now(); + const deadline = started + WINDOW_MS + 1000; + let failure: Error | undefined; + try { + for (;;) { + const now = performance.now(); + const wait = quietFor(recent, now); + if (!wait) break; + if (now + wait > deadline) throw new Error("The live API window did not quiet within 61 seconds"); + await pause(wait); + } + if (attempts.length > TEST_HEADROOM) throw new Error(`Live test used ${attempts.length} API attempts; review its ${TEST_HEADROOM}-request quota headroom`); + } catch (error) { failure = error as Error; } + finally { restore.reverse().forEach((undo) => undo()); } + + // Read only failed bodies after measurements; diagnostics have one shared + // one-second ceiling, not another unbounded wait for a broken service. + const bad: Attempt[] = []; + const pending = answers.map(async ({ attempt, response }) => { + attempt.status = response.status(); + if (attempt.status < 400 || bad.length >= 24) return; + bad.push(attempt); + attempt.error_code = "error_body_unavailable"; + try { + const headers = await response.headers(); + const retry = headers["retry-after"]; + if (/^\d{1,5}$/.test(retry ?? "")) attempt.retry_after = Number(retry); + const cache = headers["x-allthing-cache"]; + if (["fresh", "miss", "revalidating", "stale-if-error", "skipped"].includes(cache)) attempt.cache = cache; + if (attempt.path.startsWith("/api/v1/auth/")) attempt.error_code = "auth_body_omitted"; + else if (Number(headers["content-length"] ?? 0) > 4096) attempt.error_code = "error_body_too_large"; + else attempt.error_code = errorCode(attempt.status, await response.text(), attempt.path); + } catch { /* The failed status is still retained when its body vanished. */ } + }); + let timer: ReturnType | undefined; + await Promise.race([Promise.all(pending), new Promise((done) => { timer = setTimeout(done, 1000); })]); + clearTimeout(timer); + const now = performance.now(); + const counts: Record = {}; + for (const attempt of attempts) { + const key = `${attempt.method} ${attempt.path}`; + const bounded = key in counts || Object.keys(counts).length < 64 ? key : "other API paths"; + counts[bounded] = (counts[bounded] ?? 0) + 1; + } + const stripped = ({ at, ...attempt }: Attempt) => attempt; + await info.attach("live-api-window", { contentType: "application/json", body: JSON.stringify({ + scope: "same-origin API attempts, routed fixtures counted conservatively; no queries, identity or successful bodies", + attempts: attempts.length, counts, between_tests_ms: Math.round(now - started), + initial_quiet_ms: WINDOW_MS + 1, + rolling_60s_attempts: recent.filter((at) => at > now - WINDOW_MS).length, + next_test_window_below: RETAINED, individual_test_headroom: TEST_HEADROOM, + network_failures: attempts.filter((attempt) => attempt.error_code === "network_failed").slice(0, 24).map(stripped), + bad_responses: bad.map(stripped), + }, null, 2) }); + recent.splice(0, recent.length, ...recent.filter((at) => at > now - WINDOW_MS)); + if (!failure && answers.some(({ response }) => response.status() === 429)) { + failure = new Error("The live suite hit the API rate limit; inspect live-api-window before continuing"); + } + if (failure) throw failure; + }); +} diff --git a/e2e/helpers/live-request-window.mjs b/e2e/helpers/live-request-window.mjs new file mode 100644 index 00000000..96b4ab1f --- /dev/null +++ b/e2e/helpers/live-request-window.mjs @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +export const WINDOW_MS = 60_000; +export const RETAINED = 50; +export const TEST_HEADROOM = 60; + +/** Harness-only delay until fewer than fifty API attempts remain. */ +export function quietFor(attempts, now) { + const recent = attempts.filter((at) => at > now - WINDOW_MS).toSorted((a, b) => a - b); + return recent.length < RETAINED ? 0 : Math.max(0, recent[recent.length - RETAINED] + WINDOW_MS + 1 - now); +} + +/** Never keep a query, nonce, cookie, key or successful response body. */ +export function endpoint(value, origin) { + const url = new URL(value, origin); + return url.origin === origin && url.pathname.startsWith("/api/") ? url.pathname.slice(0, 96) : null; +} + +/** Fixed diagnostic vocabulary, rather than arbitrary upstream error text. */ +export function errorCode(status, body, path) { + if (path.startsWith("/api/v1/auth/")) return "auth_body_omitted"; + if (body.length > 4096) return "error_body_too_large"; + let error; + try { error = JSON.parse(body).error; } catch { return "non_json_error"; } + if (error === "Too many requests; slow down") return "api_rate_limited"; + if (error === "Still gathering this page; try again shortly") return "cache_build_pending"; + if (error === "Farcaster data source is busy; backing off") return "upstream_busy"; + if (error === "request budget exhausted") return "subrequest_budget_exhausted"; + if (/^upstream returned HTTP \d{3}$/.test(error ?? "")) return `upstream_http_${error.slice(-3)}`; + return `api_http_${status}`; +} diff --git a/e2e/hover-cards.fixture.spec.ts b/e2e/hover-cards.fixture.spec.ts index 6e14f924..e7ae65b0 100644 --- a/e2e/hover-cards.fixture.spec.ts +++ b/e2e/hover-cards.fixture.spec.ts @@ -77,6 +77,57 @@ const feedCard = (page: Page) => page.locator(`.cast-card[data-hash="${cast.hash const chipLink = (card: Locator, which: "user-link" | "display-name" | "author") => card.locator(`header.cast-meta > .byline > .user-chip > .${which}`); +// Keep a small event ledger if dismissal fails again: a remaining dialog +// alone cannot distinguish a missed Escape from a panel that reopened. +async function watchDismissal(page: Page) { + await page.addInitScript(() => { + const events: unknown[] = []; + (window as Window & { popoverDismissalEvents?: unknown[] }).popoverDismissalEvents = events; + const listeners = new Set(); + const describe = (target: EventTarget | null) => target instanceof Element + ? `${target.tagName.toLowerCase()}.${target.className}`.slice(0, 120) : null; + const record = (action: string, target: EventTarget | null = null) => { + events.push({ + at: Math.round(performance.now()), action, target: describe(target), + focus: describe(document.activeElement), windowKeys: listeners.size, + panels: [...document.querySelectorAll(".popover-panel")].slice(0, 8) + .map((panel) => ({ id: panel.id, class: panel.className })), + }); + if (events.length > 80) events.shift(); + }; + const add = window.addEventListener.bind(window); + const remove = window.removeEventListener.bind(window); + window.addEventListener = ((type, listener, options) => { + add(type, listener, options); + if (type === "keydown" && listener) { listeners.add(listener); record("listen-key"); } + }) as typeof window.addEventListener; + window.removeEventListener = ((type, listener, options) => { + remove(type, listener, options); + if (type === "keydown" && listener) { listeners.delete(listener); record("unlisten-key"); } + }) as typeof window.removeEventListener; + for (const type of ["keydown", "keyup", "mouseenter", "mouseleave", "focusin", "focusout"]) { + add(type, ((event: Event) => { + if (event instanceof KeyboardEvent) { + if (event.key === "Escape") record(`${type}:${event.key}:${event.isTrusted}`, event.target); + } else if (event.target instanceof Element && event.target.matches(".popover, .popover-panel, .user-link, .author, .display-name")) { + record(type, event.target); + } + }) as EventListener, true); + } + new MutationObserver((changes) => { + for (const change of changes) { + if (change.type === "attributes" && (change.target as Element).matches(".popover-panel")) { + record("panel-class", change.target); + } else if (change.type === "childList") { + for (const [action, nodes] of [["panel-added", change.addedNodes], ["panel-removed", change.removedNodes]] as const) { + if ([...nodes].some((node) => node instanceof Element && (node.matches(".popover-panel") || node.querySelector(".popover-panel")))) record(action); + } + } + } + }).observe(document, { subtree: true, childList: true, attributes: true, attributeFilter: ["class"] }); + }); +} + // Hovering needs a mouse; touch screens open cards with a tap (tested below). test.beforeEach(({}, testInfo) => { test.skip(Boolean(testInfo.project.use.hasTouch) && !testInfo.titlePath.some((t) => t.includes("touch")), "hover needs a mouse"); @@ -84,6 +135,7 @@ test.beforeEach(({}, testInfo) => { test("cards in view preload their authors' vote and wallets, so a hovered card opens complete", async ({ page }) => { const requests = await serve(page); + await watchDismissal(page); await page.goto("/"); await expect(chipLink(feedCard(page), "author")).toHaveText("@alice"); // The three authors in view: their vote and wallets, before any hover. @@ -121,13 +173,48 @@ test("cards in view preload their authors' vote and wallets, so a hovered card o await expect(profile.getByRole("link", { name: "View profile →", exact: true })).toHaveAttribute("href", "/u/alice"); await page.keyboard.press("Escape"); - await expect(profile).toHaveCount(0); + try { + await expect(profile).toHaveCount(0); + } catch (error) { + const events = await page.evaluate(() => (window as Window & { popoverDismissalEvents?: unknown[] }).popoverDismissalEvents); + await test.info().attach("popover-dismissal-events", { body: JSON.stringify(events, null, 2), contentType: "application/json" }); + throw error; + } // A mouse click on the handle goes to the profile, as before. await chipLink(feedCard(page), "author").click(); await expect(page).toHaveURL(/\/u\/alice$/); }); +test("Escape stays dismissed when the pointer re-enters during the closing animation", async ({ page }) => { + await serve(page); + await page.goto("/"); + await appCame(page); + const picture = chipLink(feedCard(page), "user-link"); + await picture.hover(); + const profile = profileOf(page, "Alice", "alice"); + await expect(profile).toBeVisible(); + const box = (await picture.boundingBox())!; + + // Freeze only after opening, then use real pointer events during Closing. + // Advancing beyond both timers rules out a transient zero-dialog gap: + // before the fix the 100 ms re-entry dwell reopened the 120 ms close. + await page.clock.install(); + await page.clock.pauseAt(await page.evaluate(() => Date.now() + 1000)); + await page.keyboard.press("Escape"); + await expect(profile).toHaveClass(/closing/); + await page.mouse.move(5, 5); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.clock.runFor(300); + await expect(profile).toHaveCount(0); + + // A fresh hover after closing still opens the card normally. + await page.mouse.move(5, 5); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.clock.runFor(100); + await expect(profile).toBeVisible(); +}); + test("a card's picture shows the small copy the feed loaded until its own has loaded", async ({ page }) => { await serve(page); // Alice has a picture. The feed's small copy comes at once; the card's diff --git a/e2e/loading-layout.fixture.spec.ts b/e2e/loading-layout.fixture.spec.ts index da3026f1..e030f13c 100644 --- a/e2e/loading-layout.fixture.spec.ts +++ b/e2e/loading-layout.fixture.spec.ts @@ -97,7 +97,13 @@ for (const size of sizes) { expect(await top(first)).toBe(where); await press(page.locator(".sort-tabs a", { hasText: /^hot$/ })); - await expect(first).toContainText("Hot 0"); + await expect.poll(async () => { + // The initial Hot screen may now be seen. Restore its first row + // before comparing the same row's exact settled position. + const show = page.getByRole("button", { name: "Show seen posts", exact: true }); + if (await show.isVisible()) await show.click(); + return page.locator(".cast-list .cast-card[data-hash]").evaluateAll((cards) => cards[0]?.textContent ?? ""); + }).toContain("Hot 0"); await settled(page); expect(await top(first)).toBe(where); }); diff --git a/e2e/notifications.fixture.spec.ts b/e2e/notifications.fixture.spec.ts index fcc326ec..2e2eb118 100644 --- a/e2e/notifications.fixture.spec.ts +++ b/e2e/notifications.fixture.spec.ts @@ -123,6 +123,26 @@ async function ready(page: Page, seen = SEEN_BEFORE, opts: { failFirst?: number return serve(page, opts); } +/** Hold the actual page module while the bell's initial response settles. */ +async function delayPageCode(page: Page) { + let release: () => void = () => {}; + const held = new Promise((done) => (release = done)); + let asked = 0; + await page.route("**/split_notifications.wasm", async (route) => { + asked += 1; + await held; + await route.continue(); + }); + return { release, asked: () => asked }; +} + +/** Let the completed response reach the bell before releasing the page. */ +async function bellSettled(page: Page) { + await page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + })); +} + const bell = (page: Page) => page.getByRole("link", { name: /^Notifications, \d+ unread$/ }); const rows = (page: Page) => page.locator(".notif-list > li.notif:not(.skeleton)"); const shot = async (target: Page | Locator, name: string, testInfo: TestInfo) => { @@ -416,16 +436,48 @@ test("signed out, there's no bell, and the page asks to sign in", async ({ page expect(calls.first).toBe(0); }); -test("a read that fails says why, and Try again reads again", async ({ page }) => { +test("a read that fails before the page code arrives says why, and Try again reads again", async ({ page }) => { const calls = await ready(page, SEEN_BEFORE, { failFirst: 1 }); - await page.goto("/notifications"); - const failed = page.locator(".notif-error[role=alert]"); - await expect(failed).toContainText("Farcaster data source is busy"); - await expect(page.locator(".bell-badge")).toHaveCount(0); - expect(calls.first).toBe(1); - await failed.getByRole("button", { name: "Try again" }).click(); - await expect(rows(page)).toHaveCount(7); - expect(calls.first).toBe(2); + const code = await delayPageCode(page); + try { + const response = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/v1/notifications" && response.status() === 503); + await page.goto("/notifications", { waitUntil: "domcontentloaded" }); + await expect.poll(code.asked).toBeGreaterThan(0); + expect(await (await response).finished()).toBeNull(); + await bellSettled(page); + // The page is not mounted yet, but the bell's failed read is complete. + await expect(page.getByRole("heading", { name: "Notifications", exact: true })).toHaveCount(0); + expect(calls.first).toBe(1); + code.release(); + + const failed = page.locator(".notif-error[role=alert]"); + await expect(failed).toContainText("Farcaster data source is busy"); + await expect(page.locator(".bell-badge")).toHaveCount(0); + expect(calls.first).toBe(1); + await failed.getByRole("button", { name: "Try again" }).click(); + await expect(rows(page)).toHaveCount(7); + expect(calls.first).toBe(2); + } finally { + code.release(); + } +}); + +test("a fresh bell answer arriving before the page code is reused without another request", async ({ page }) => { + const calls = await ready(page); + const code = await delayPageCode(page); + try { + await page.goto("/notifications", { waitUntil: "domcontentloaded" }); + await expect.poll(code.asked).toBeGreaterThan(0); + await expect(bell(page)).toHaveAccessibleName("Notifications, 3 unread"); + await expect(page.getByRole("heading", { name: "Notifications", exact: true })).toHaveCount(0); + expect(calls.first).toBe(1); + code.release(); + await expect(rows(page)).toHaveCount(7); + await expect(bell(page)).toHaveAccessibleName("Notifications, 0 unread"); + expect(calls.first).toBe(1); + } finally { + code.release(); + } }); test("nothing yet, per tab", async ({ page }) => { diff --git a/e2e/reply-composer.fixture.spec.ts b/e2e/reply-composer.fixture.spec.ts index 3859e2cc..526e679f 100644 --- a/e2e/reply-composer.fixture.spec.ts +++ b/e2e/reply-composer.fixture.spec.ts @@ -73,12 +73,30 @@ async function openPost(page: Page, signedIn = true) { await page.goto(`/p/${post.hash}`); await expect(page.locator(".post")).toBeVisible(); await expect(page.locator(".comment")).toHaveCount(1); + // The assertion measures opening Reply, after the underlying fixture is + // ready. Do not fold late initial fonts/images or entrance layout into it. + // Composer media remains untouched: shifts after the press still count. + await page.evaluate(async () => { + await document.fonts.ready; + const images = [...document.images].filter((image) => { + const box = image.getBoundingClientRect(); + return image.complete || (box.width > 0 && box.bottom > 0 && box.top < innerHeight); + }); + await Promise.all(images.map((image) => image.decode().catch(() => undefined))); + await Promise.all(document.getAnimations() + .filter((animation) => Number.isFinite(animation.effect?.getComputedTiming().endTime ?? Infinity)) + .map((animation) => animation.finished.catch(() => undefined))); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); + }); await page.evaluate(() => { - const w = window as unknown as { __shifts: { value: number; at: number; sources: string[] }[] }; + const w = window as unknown as { __shifts: { value: number; at: number; sources: object[] }[] }; w.__shifts = []; new PerformanceObserver((list) => { - for (const entry of list.getEntries() as unknown as { value: number; startTime: number; sources?: { node?: Element }[] }[]) { - w.__shifts.push({ value: entry.value, at: entry.startTime, sources: (entry.sources ?? []).map((s) => s.node?.className ?? "?") }); + for (const entry of list.getEntries() as unknown as { value: number; startTime: number; sources?: { node?: Element; previousRect: DOMRectReadOnly; currentRect: DOMRectReadOnly }[] }[]) { + const rect = (r: DOMRectReadOnly) => ({ x: r.x, y: r.y, width: r.width, height: r.height }); + w.__shifts.push({ value: entry.value, at: entry.startTime, sources: (entry.sources ?? []).slice(0, 5).map((s) => ({ + tag: s.node?.tagName ?? "?", classes: s.node?.getAttribute?.("class") ?? "", previous: rect(s.previousRect), current: rect(s.currentRect), + })) }); } }).observe({ type: "layout-shift" }); }); @@ -149,13 +167,17 @@ async function barMs(page: Page) { /** How much moved after `at` (a frame's time), in a second's watching: CLS's sum, input or not. */ async function shiftAfter(page: Page, at: number) { await page.waitForTimeout(1000); - return page.evaluate( + const measured = await page.evaluate( (at) => - (window as unknown as { __shifts: { value: number; at: number; sources: string[] }[] }).__shifts - .filter((s) => s.at > at) - .reduce((sum, s) => sum + s.value, 0), + (window as unknown as { __shifts: { value: number; at: number; sources: object[] }[] }).__shifts + .filter((s) => s.at > at), at, ); + const total = measured.reduce((sum, shift) => sum + shift.value, 0); + if (total >= 0.01) await test.info().attach("reply-layout-shifts.json", { + body: JSON.stringify({ after: at, total, shifts: measured.slice(-32) }), contentType: "application/json", + }); + return total; } /** Accessibility problems axe finds in `scope`, once what animates in has. */ diff --git a/e2e/search-typeahead.fixture.spec.ts b/e2e/search-typeahead.fixture.spec.ts index 4fb71edc..d1602452 100644 --- a/e2e/search-typeahead.fixture.spec.ts +++ b/e2e/search-typeahead.fixture.spec.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import { expect, test, type Page } from "@playwright/test"; import { NOW } from "./fixture-time"; +import { appCame } from "./helpers/app"; test.skip(Boolean(process.env.E2E_PAGES), "uses the Worker API contract"); const meta = { backend: "hypersnap", fetched_at: Date.now() }; @@ -24,11 +25,90 @@ async function serve(page: Page) { return queries; } +/** Playwright's 1e12 timer IDs must survive the browser's WebIDL i32 ABI. */ +async function browserClock(page: Page) { + await page.clock.install({ time: Date.now() }); + await page.addInitScript(() => { + // Native browser handles fit i32. Without aliases, web-sys truncates the + // clock's 1e12 handles and cancellation silently leaves old timers live. + // All timer families share this allocator so their aliases cannot collide. + const target = window as unknown as Record; + const handles = new Map(); + let next = 1; + for (const [schedule, cancel, once] of [ + ["setTimeout", "clearTimeout", true], ["setInterval", "clearInterval", false], + ["requestAnimationFrame", "cancelAnimationFrame", true], ["requestIdleCallback", "cancelIdleCallback", true], + ] as const) { + const original = target[schedule]; + const clear = target[cancel]; + if (typeof original !== "function" || typeof clear !== "function") continue; + target[schedule] = (callback: Function | string, ...args: unknown[]) => { + if (next > 0x7fffffff) throw new Error("The fixture clock exhausted browser timer IDs"); + const id = next++; + const task = typeof callback === "function" && once ? function (this: unknown, ...values: unknown[]) { + handles.delete(id); + return Reflect.apply(callback, this, values); + } : callback; + handles.set(id, Reflect.apply(original, window, [task, ...args])); + return id; + }; + target[cancel] = (id: number) => { + const raw = handles.get(id) ?? id; + handles.delete(id); + return Reflect.apply(clear, window, [raw]); + }; + } + }); +} + test("suggestions appear as you type: people, channels and posts", async ({ page }) => { const queries = await serve(page); + // Install before the app binds any timer APIs. + await browserClock(page); await page.goto("/"); + await appCame(page); const box = page.getByRole("combobox", { name: "Search casts, people and channels", exact: true }); - await box.pressSequentially("fixt", { delay: 40 }); + // Keep real keyboard/input events, but control the gaps: a busy host can + // otherwise turn a requested 40 ms gap into a genuine 250 ms pause. + await page.clock.pauseAt(await page.evaluate(() => Date.now() + 1000)); + const handles = await page.evaluate(() => { + (window as any).__clockProbe = []; + const note = (...args: unknown[]) => (window as any).__clockProbe.push(args); + const timeout = setTimeout(note, 16, "cancelled timeout"); + clearInterval(timeout | 0); + const interval = setInterval(note, 8, "cancelled interval"); + clearTimeout(interval | 0); + const frame = requestAnimationFrame(() => note("cancelled frame")); + cancelAnimationFrame(frame | 0); + const live = setTimeout(note, 16, "kept", 7); + return [timeout, interval, frame, live]; + }); + expect(new Set(handles).size).toBe(handles.length); + expect(handles.every((id) => id > 0 && id === (id | 0))).toBe(true); + await page.clock.runFor(15); + expect(await page.evaluate(() => (window as any).__clockProbe)).toEqual([]); + await page.clock.runFor(1); + expect(await page.evaluate(() => (window as any).__clockProbe)).toEqual([["kept", 7]]); + await box.evaluate((input: HTMLInputElement) => { + (window as any).__searchInputs = []; + input.addEventListener("input", (event) => { + (window as any).__searchInputs.push({ value: input.value, trusted: event.isTrusted, at: Date.now() }); + }); + }); + for (const [i, character] of [..."fixt"].entries()) { + if (i) await page.clock.runFor(40); + await box.pressSequentially(character); + } + const inputs = await page.evaluate(() => (window as any).__searchInputs as { value: string; trusted: boolean; at: number }[]); + expect(inputs.map((input) => input.value)).toEqual(["f", "fi", "fix", "fixt"]); + expect(inputs.every((input) => input.trusted)).toBe(true); + expect(inputs.slice(1).map((input, i) => input.at - inputs[i].at)).toEqual([40, 40, 40]); + expect(queries).toEqual([]); + await page.clock.runFor(249); + expect(queries).toEqual([]); + await page.clock.runFor(1); + await expect.poll(() => queries).toEqual(["fixt"]); + await page.clock.resume(); const list = page.getByRole("listbox", { name: "Suggestions" }); await expect(list).toBeVisible(); // One look-up once typing pauses, not one per key. diff --git a/e2e/smoothness.fixture.spec.ts b/e2e/smoothness.fixture.spec.ts index d729ce91..201289c0 100644 --- a/e2e/smoothness.fixture.spec.ts +++ b/e2e/smoothness.fixture.spec.ts @@ -254,6 +254,82 @@ type Sample = { frames: { t: number; blank: boolean; skeleton: boolean; placeholder: boolean; white: boolean; anchor: number | null; scrollY: number; path: string }[]; }; +/** Diagnostic retention only; `measure` always receives the complete original Sample. */ +const RAW_LIMITS = { samples: 80, events: 64, longtasks: 64, loafs: 64, shifts: 64, frames: 240, scripts: 4 } as const; +type Rows = "events" | "longtasks" | "loafs" | "shifts" | "frames"; +type RetainedSample = { sample: Sample; totals: Record }; +type RetainedSamples = { observed: number; samples: RetainedSample[] }; +const rawSamples = new WeakMap(); + +/** A shallow bounded diagnostic copy; no mutation or truncation of metric inputs. */ +function retainSample(page: Page, sample: Sample) { + const kept = rawSamples.get(page); + if (!kept) return; + kept.observed += 1; + if (kept.samples.length >= RAW_LIMITS.samples) return; + kept.samples.push({ + sample: { + ...sample, + events: sample.events.slice(0, RAW_LIMITS.events), + longtasks: sample.longtasks.slice(0, RAW_LIMITS.longtasks), + loafs: sample.loafs.slice(0, RAW_LIMITS.loafs), + shifts: sample.shifts.slice(0, RAW_LIMITS.shifts), + frames: sample.frames.slice(0, RAW_LIMITS.frames), + }, + totals: { events: sample.events.length, longtasks: sample.longtasks.length, loafs: sample.loafs.length, shifts: sample.shifts.length, frames: sample.frames.length }, + }); +} + +/** Serialize only after the profile's browser context closes, outside all measured windows. */ +function rawDiagnostic(profile: Profile, kept: RetainedSamples, measurementComplete: boolean) { + const text = (s: string, cap = 160) => String(s).slice(0, cap); + const url = (s: string) => { + const raw = String(s); + if (!raw) return raw; + try { + const parsed = new URL(raw, "https://diagnostic.invalid"); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "[non-http URL]"; + // The existing observer keeps source basenames; frames keep relative routes. + // Drop credentials, query strings and fragments from either URL form. + const safe = /^(?:https?:)?\/\//i.test(raw) ? parsed.origin + parsed.pathname + : raw.startsWith("/") ? parsed.pathname : parsed.pathname.replace(/^\//, ""); + return text(safe, 320); + } catch { + return "[invalid URL]"; + } + }; + return { + version: 1, + profile, + measurementComplete, + note: "Bounded diagnostics only. Metrics use every original Sample entry; truncation never changes a budget result. Script entries were already capped at four per frame by the existing observer.", + limits: RAW_LIMITS, + samplesObserved: kept.observed, + samplesRetained: kept.samples.length, + samplesOmitted: kept.observed - kept.samples.length, + strategy: "first entries, without sampling or metric subtraction", + samples: kept.samples.map(({ sample, totals }) => ({ + label: text(sample.label), t0: sample.t0, t1: sample.t1, + counts: Object.fromEntries((Object.keys(totals) as Rows[]).map((key) => [key, { + observed: totals[key], retained: sample[key].length, omitted: totals[key] - sample[key].length, + }])), + events: sample.events.map((e) => ({ id: e.id, name: text(e.name, 64), start: e.start, dur: e.dur, processed: e.processed })), + longtasks: sample.longtasks.map((t) => ({ start: t.start, dur: t.dur })), + loafs: sample.loafs.map((f) => ({ + start: f.start, dur: f.dur, blocking: f.blocking, + scriptsObserved: f.scripts.length, + scriptsOmitted: Math.max(0, f.scripts.length - RAW_LIMITS.scripts), + scripts: f.scripts.slice(0, RAW_LIMITS.scripts).map((script) => ({ invoker: text(script.invoker, 80), dur: script.dur, fn: text(script.fn), src: url(script.src) })), + })), + shifts: sample.shifts.map((shift) => ({ start: shift.start, value: shift.value, recent: shift.recent, sources: shift.sources.slice(0, 3).map((source) => text(source)) })), + frames: sample.frames.map((frame) => ({ + t: frame.t, blank: frame.blank, skeleton: frame.skeleton, placeholder: frame.placeholder, white: frame.white, + anchor: frame.anchor, scrollY: frame.scrollY, path: url(frame.path), + })), + })), + }; +} + /** * Installed before the app: observers for Event Timing, long tasks, long * animation frames and layout shifts, a frame sampler, and the press probe. @@ -604,7 +680,9 @@ async function begin(page: Page, label: string, anchor: string | null = null) { async function end(page: Page, ms = 1100): Promise { await page.waitForTimeout(ms); - return page.evaluate(() => (window as any).__smooth.end()); + const sample: Sample = await page.evaluate(() => (window as any).__smooth.end()); + retainSample(page, sample); + return sample; } /** @@ -1001,6 +1079,9 @@ const results: Partial> = {}; async function run(browser: Browser, profile: Profile): Promise { const tracing = traced(profile); const { context, page, calls, cdp } = await open(browser, profile); + const kept: RetainedSamples = { observed: 0, samples: [] }; + rawSamples.set(page, kept); + let measurementComplete = false; // Its screencast, actions and network; DOM snapshots would slow it more. if (tracing) await context.tracing.start({ screenshots: true, snapshots: false }); try { @@ -1029,14 +1110,25 @@ async function run(browser: Browser, profile: Profile): Promise { const scroll = await timed(() => scrollFeed(page, profile, cdp)); const pressed = await timed(() => presses(page, profile, cdp)); const machine = { share: round(median(shares), 2), shares: shares.map((s) => round(s, 2)), load: round(loadavg()[0], 1), cpus: cpus().length }; + measurementComplete = true; return { profile, scenarios, presses: pressed, scroll, machine }; } catch (error) { // Where it stopped, for the report CI uploads. await page.screenshot({ path: test.info().outputPath(`stopped-${profile.name}.png`) }).catch(() => {}); throw error; } finally { - if (tracing) await context.tracing.stop({ path: process.env.SMOOTH_TRACE }); - await context.close(); + try { + if (tracing) await context.tracing.stop({ path: process.env.SMOOTH_TRACE }); + } finally { + try { + await context.close(); + } finally { + rawSamples.delete(page); + await test.info().attach(`smoothness-raw-${profile.name}.json`, { + body: JSON.stringify(rawDiagnostic(profile, kept, measurementComplete)), contentType: "application/json", + }); + } + } } } diff --git a/package-lock.json b/package-lock.json index 85260221..200f8759 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1381,9 +1381,9 @@ } }, "node_modules/miniflare/node_modules/undici": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", - "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index ebc228ef..fe66ff76 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,9 @@ "wrangler": "4.147.0" }, "overrides": { + "miniflare@5.20261001.0-alpha": { + "undici": "7.30.1" + }, "sharp": "0.35.5" } } diff --git a/tests/live-request-window.test.mjs b/tests/live-request-window.test.mjs new file mode 100644 index 00000000..0e07384a --- /dev/null +++ b/tests/live-request-window.test.mjs @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import assert from "node:assert/strict"; +import test from "node:test"; +import { endpoint, errorCode, TEST_HEADROOM, WINDOW_MS, quietFor } from "../e2e/helpers/live-request-window.mjs"; + +test("rolling-window pacing leaves test headroom without delaying an interaction", () => { + assert.equal(quietFor(Array(49).fill(1000), 1000), 0); + assert.equal(quietFor(Array(50).fill(1000), 1000), WINDOW_MS + 1); + assert.equal(quietFor(Array(50).fill(1000), 61_000), 0); + const bursts = [...Array(25).fill(1000), ...Array(25).fill(2000), ...Array(20).fill(3000)]; + const wait = quietFor(bursts, 3000); + assert.equal(wait, 58_001); + assert.equal(quietFor(bursts, 3000 + wait), 0); + assert.ok(49 + TEST_HEADROOM < 120); +}); + +test("accounting excludes other origins/assets and strips private query values", () => { + const origin = "https://allthing.social"; + assert.equal(endpoint("/api/v1/home?private=value", origin), "/api/v1/home"); + assert.equal(endpoint("/api/v1/auth/qr?nonce=secret", origin), "/api/v1/auth/qr"); + assert.equal(endpoint("https://example.com/api/v1/home", origin), null); + assert.equal(endpoint("/m/123/image.png", origin), null); + assert.equal(endpoint("/assets/allthing.js", origin), null); +}); + +test("error diagnostics distinguish busy causes without retaining private bodies", () => { + const path = "/api/v1/channels"; + assert.equal(errorCode(429, JSON.stringify({ error: "Too many requests; slow down" }), path), "api_rate_limited"); + assert.equal(errorCode(503, JSON.stringify({ error: "Still gathering this page; try again shortly" }), path), "cache_build_pending"); + assert.equal(errorCode(503, JSON.stringify({ error: "Farcaster data source is busy; backing off" }), path), "upstream_busy"); + assert.equal(errorCode(503, JSON.stringify({ error: "request budget exhausted" }), path), "subrequest_budget_exhausted"); + assert.equal(errorCode(502, JSON.stringify({ error: "upstream returned HTTP 503" }), path), "upstream_http_503"); + assert.equal(errorCode(500, JSON.stringify({ error: "secret https://private.invalid/?key=secret" }), path), "api_http_500"); + assert.equal(errorCode(400, JSON.stringify({ nonce: "secret" }), "/api/v1/auth/qr"), "auth_body_omitted"); + assert.equal(errorCode(503, "edge unavailable", path), "non_json_error"); + assert.equal(errorCode(503, "x".repeat(4097), path), "error_body_too_large"); +});