diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0702a7ac..9a7c765c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,6 +90,16 @@ jobs: - run: npx playwright test e2e/perf.spec.ts env: E2E_BASE_URL: https://allthing.social + - name: Retain performance diagnostics + if: always() + uses: actions/upload-artifact@v6 + with: + name: playwright-performance + path: | + playwright-report/ + test-results/ + if-no-files-found: ignore + retention-days: 7 speed: # The speed report (docs/PERFORMANCE.md#speed-report): load times of the @@ -116,7 +126,7 @@ jobs: browser: name: browser regressions (fixtures) runs-on: ubuntu-24.04 - # The suite takes 26 to 29 minutes; 30 cut runs off at their last specs. + # Build, functional fixtures, quiet smoothness and static checks share this cap. timeout-minutes: 45 env: WRANGLER_SEND_METRICS: "false" @@ -142,11 +152,13 @@ jobs: - run: npx playwright install --with-deps chromium - run: bash scripts/build.sh - name: Test the local build without live data - run: npx playwright test '\.fixture\.spec\.ts$' + run: sh scripts/test-browser-fixtures.sh - name: Test the static copy run: | bash scripts/build-pages.sh - 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 + 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 --output=test-results/static + env: + PLAYWRIGHT_HTML_OUTPUT_DIR: playwright-report/static - name: Upload browser failure reports if: failure() uses: actions/upload-artifact@v6 diff --git a/CHANGELOG.md b/CHANGELOG.md index a3369a2d..33e14a09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,45 +5,41 @@ All notable changes to Allthing are recorded here. The format follows [Keep a Ch ## [Unreleased] -### Changed - -- Notifications coalesce refreshes from opening the page and regaining focus. Marking the list seen does not queue another read: only changed notification times refresh its rows, so a delayed duplicate cannot start after the tab is hidden. - -- On phones, header search has a full-width second row, leaving Home, the online count, Settings and account controls in view. A wrapped reply's app link clears its collapse button's full tap area. - -- Front-page feeds remember casts actually read for a second with at least half its possible viewport exposure visible in a foreground tab, including cards taller than the screen. Later feed generations leave these posts out, with Show seen posts below the list and Reset seen posts in Settings. History stays in this browser, separate for each signed-in FID and signed-out reader, bounded to 2,000 hashes for 30 days across four readers. Current cards stay put while read, and direct posts, profiles and threads remain complete. +## [0.5.1] - 2026-10-23 -- Show mini app casts moved out of the feed into a Settings panel in the header (for every reader, signed in or not): the "N mini app casts hidden" line is removed, and the account menu no longer repeats the switch. A shown mini app cast quoted in another cast now carries its "Open in Farcaster" link inside the quote card; no other cast or quote shows mini app chrome. Quoted mini apps follow the switch in place; early cards end before a shown mini app so the app takes over without replacing it with different markup. - -- The "Only N casts in …" note sits under a range's list, where it ends, not above it: it came once the list had ended and moved every card down (a layout shift at 1280). -- The front page has two tabs for a signed-in reader, as on X: **For you** and **Following**, links in place of the Following / Everyone menu, each 44 px tall. For you, the default, is the trending pool with the casts of the accounts you follow lifted: 20 times their points in hot and 6 in top (`FOLLOWED_HOT_WEIGHT`, `FOLLOWED_TOP_WEIGHT`), and new stays in time order. Following (`?feed=following`) is only their casts, in hot, new and top and over the time range, with no trending casts topping it up any more. Old `?feed=everyone` links open For you. For you costs no extra request while it is drawn: it ranks by the accounts kept from your last Following page, renewed by one background read at most every 10 minutes. Signed out, the front page has no tabs, as before; early paint draws a signed-in reader's For you, tabs and all. +### Changed -- Messages now only take verified Farcaster accounts: allthing.social's attestation key is set up (the `ATTEST_KEY` secret) and its public half, `5df241b6517235a521769788541a5cb6369bb3deb4536734b5db8b5cb23d87cb`, is pinned in the SpacetimeDB module (`spacetime/spacetimedb/src/attest_key.rs`), as `/api/v1/status` reports. Signed in, Messages verifies your account before you chat or send a message; no one can speak there as someone else's FID. +- **For you and Following.** Signed-in readers have two front-page tabs, each with hot, new and top and every time range. For you lifts casts from accounts you follow; Following shows their casts only. New stays chronological, including with Favorite and Show less. +- **Seen posts.** Later front-page visits leave out casts you read for a second in a visible tab, including cards taller than the screen. Show seen posts brings them back, and Reset seen posts clears the history in Settings. History stays in this browser, separate for each reader: at most 2,000 hashes for 30 days, across four readers. Cards stay put while you read; direct posts, profiles and threads stay complete. +- **Mini apps in Settings.** Show mini app casts is in the header's Settings panel for every reader, replacing the feed's hidden-cast count and switch. Only casts and quotes that contain mini apps show their Open in Farcaster links, and quoted mini apps follow the setting immediately. +- **A clearer header.** Phone search has a full-width second row, keeping Home, the online count, Settings and account controls in view. The online count reserves its space on desktop and stays beside Home on phones. +- **Verified Messages.** Messages verifies the signed-in Farcaster account before it can chat or send messages. The Worker's attestation key is pinned in the SpacetimeDB module, which rejects forged and expired attestations. ### Fixed -- Feed-ranking checks restore read posts through Show seen posts even when the filtered list is empty or a delayed navigation is still loading. - -- Reply-composer geometry checks wait for its entrance animation before measuring exact thumbnail and tool sizes, while retaining first-frame focus and layout-shift checks. - -- Pressed conversation rows keep their preview text and timestamps readable while the press animation dims the row, including on its selected background. -- The smoothness fixture locates the first feed card below both front-page tab rows before measuring navigation; its existing timing, frame and scroll-restoration budgets are unchanged. - -- Messages connects under the site’s existing Content Security Policy. SpacetimeDB’s product and sum codecs now use cached closures instead of compiling JavaScript strings; the SDK version is guarded at build time, and a browser test exercises the actual bundled connection, row decoding, account verification and chat sending under the production policy. - -- Following sort links and time-range changes prefetch their existing private feed, rather than fetching and waiting for unrelated For you home/latest payloads. -- The desktop online count reserves its width before loading, including at 921 px with Settings present, so the search box cannot overlap it. Phone layouts keep the compact dot and count at the 920 px breakpoint. - -- Following stops automatic pagination once its source reaches the selected time limit, and retains that cursor so a wider range resumes correctly. A fully seen or otherwise filtered feed reads at most ten payloads before offering Load more posts; each explicit press permits ten more. - -- Local macOS Worker and static Pages builds discover worker-build's cached `wasm-opt` under `Library/Caches`, so the browser modules receive the same optimization as CI. An explicit optimizer or one on PATH takes precedence, and cached binaries for other architectures are skipped. - -- Both front-page tabs keep the time selector in New and apply the selected range consistently. New stays strictly chronological even for favorites and accounts shown less. Changing the signed-in account replaces its feed and ranking snapshot; background follow reads and their ten-minute retry cap are scoped to that account, and late answers from the old account cannot refill its private cache. -- The online count stays visible beside Home on phones, with its space reserved before loading. Showing oneself or signing in replaces the browser's anonymous presence instead of counting it twice; hiding or signing out removes the public name, including from later cached responses at other locations. Beat jitter only delays the advertised interval by up to 30 seconds, within the online grace, instead of beating too early or expiring a reader too soon. - - -- `scripts/attest-key.mjs` writes a public key that compiles: its pattern stopped at the `;` inside the placeholder `[0; 32]`, leaving `[0x…]; 32];` in `attest_key.rs`. It now replaces the whole array, also once a key is pinned over several lines. -- Every time range up to 7 days holds the casts just posted. Hypersnap's trending lists run 4 to 6 hours behind (its past hour's list was 5 to 6 hours old, so Past hour could come back empty), so a range's first page adds the newest casts the cron stores each minute, kept to the range. +- 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. +- Sort tabs show their selected state before the next feed hides read cards, keeping reading-history cleanup out of the tap handler. +- Future-dated casts cannot outrank the present in For you or Following, including All time and early paint. Cached newest-cast payloads and index rankings exclude future timestamps before selecting their visible rows. Source reads use the clock when they finish, so casts posted while a response arrives are retained. Ingest retains up to five seconds of source clock skew in the existing buffer; implausible dates are discarded, and retained rows appear only when their timestamp is reached. +- A directly opened mini app post follows Show mini app casts immediately in both directions, without a reload or refetch. Hiding it removes its body and embedded content while retaining its Open in Farcaster link; ordinary posts and ordinary quotes keep their content and media mounted. +- Short time ranges include newly posted casts even while Hypersnap's trending list lags behind. Every sort keeps the chosen range, and the end-of-range note appears below the list without moving cards down. +- 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. +- A wrapped reply's app link clears the collapse button's full tap area. + +### Development + +- 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. +- 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. ## [0.5.0] - 2026-10-22 @@ -595,7 +591,8 @@ Released before tagging began; the link points to its last commit, `e521cd1`. - A read-only Next.js prototype with live Hypersnap data, on GitHub Pages. -[Unreleased]: https://github.com/ael-dev3/Allthing/compare/v0.5.0...HEAD +[Unreleased]: https://github.com/ael-dev3/Allthing/compare/v0.5.1...HEAD +[0.5.1]: https://github.com/ael-dev3/Allthing/compare/v0.5.0...v0.5.1 [0.5.0]: https://github.com/ael-dev3/Allthing/compare/v0.4.0...v0.5.0 [0.4.0]: https://github.com/ael-dev3/Allthing/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/ael-dev3/Allthing/compare/v0.2.0...v0.3.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 531e0391..9cece43c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,8 +7,8 @@ Everyone taking part is expected to follow the [code of conduct](CODE_OF_CONDUCT ## Ground rules -- **Reads, and the reader's own likes, recasts, casts, follows and deletions.** Don't add other ways to sign in, wallet connections or anything else that writes to Farcaster unless that scope has been agreed in an issue first. The one sign-in is Sign In With Farcaster by QR code ([docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#sign-in)); a reader's likes, recasts, casts, follows and deletions are signed in their browser, with a key they approved in the Farcaster app ([docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#liking-and-recasting)). -- **Never hide content** because of who posted it: Allthing shows no account labels. Casts with mini apps or frames, and spam by the fixed rules in `allthing-core/src/spam.rs`, are left out server-side (`Cast::hidden`, `thread::without_hidden`, `thread::prune_hidden`), and new list endpoints must do the same. +- **Reads, and the reader's own likes, recasts, casts, follows and deletions.** Don't add other ways to sign in, wallet connections or anything else that writes to Farcaster unless that scope has been agreed in an issue first. Where the Worker endorses keys, one approval in the Farcaster app signs the reader in and permits writing, by QR code on a computer or a link on a phone; Sign In With Farcaster by QR code is the fallback where it does not ([docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#sign-in)). A reader's likes, recasts, casts, follows and deletions are signed in their browser, with a key they approved in the Farcaster app ([docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#liking-and-recasting)). +- **Never hide content** because of who posted it: Allthing shows no account labels. Spam by the fixed content rules in `allthing-core/src/spam.rs` is left out server-side (`thread::without_spam`, `thread::prune_spam`). Mini app and frame casts are marked with `Cast.mini_app` and kept in feed, profile, channel and thread payloads; the browser hides them unless Show mini app casts is enabled in Settings, and never runs them. Search, reply previews and notifications still drop both spam and mini apps (`Cast::hidden`). New list endpoints must follow one of these documented paths. - **Stay within the Free plan.** Each page view makes one API call. The Worker has a 10 ms CPU limit and at most 45 subrequests per invocation, cache calls included. New upstream calls go through `allthing-data/src/upstream.rs` (token buckets, breaker, timeouts) and payloads through `cache::serve`. Update [docs/BUDGET.md](docs/BUDGET.md) when the costs change. - **Put code where it belongs.** Pure logic and data types go in `allthing-core`, with unit tests. Provider quirks stay in `allthing-data/src/hypersnap.rs` and `allthing-core/src/normalize.rs`. The page builders in `allthing-data` also run in the browser for the static copy, so they reach the network only through the `Host` trait. UI goes in `allthing-web`, and its links are built with the helpers in `allthing-web/src/util.rs` (and opened from code with `use_go` there), because the static copy lives under `/Allthing`. - **Keep it fast.** Stay within the budget in [docs/PERFORMANCE.md](docs/PERFORMANCE.md), and send prefetches through `api::prefetch`. diff --git a/Cargo.lock b/Cargo.lock index 4d317452..76269df2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,7 +23,7 @@ dependencies = [ [[package]] name = "allthing-core" -version = "0.5.0" +version = "0.5.1" dependencies = [ "blake3", "chacha20poly1305", @@ -37,7 +37,7 @@ dependencies = [ [[package]] name = "allthing-data" -version = "0.5.0" +version = "0.5.1" dependencies = [ "allthing-core", "futures", @@ -47,7 +47,7 @@ dependencies = [ [[package]] name = "allthing-web" -version = "0.5.0" +version = "0.5.1" dependencies = [ "allthing-core", "allthing-data", @@ -66,7 +66,7 @@ dependencies = [ [[package]] name = "allthing-worker" -version = "0.5.0" +version = "0.5.1" dependencies = [ "allthing-core", "allthing-data", @@ -2753,7 +2753,7 @@ dependencies = [ [[package]] name = "xtask" -version = "0.5.0" +version = "0.5.1" dependencies = [ "allthing-core", "wasm_split_cli_support", diff --git a/Cargo.toml b/Cargo.toml index 03f6f074..8ec7b7b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ exclude = ["spacetime/spacetimedb"] resolver = "3" [workspace.package] -version = "0.5.0" +version = "0.5.1" edition = "2024" rust-version = "1.99" license = "AGPL-3.0-or-later" diff --git a/README.md b/README.md index e87c9c3c..4d88b37e 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Allthing is an independent Farcaster client, written in Rust, that shows trendin **Live at [allthing.social](https://allthing.social).** -![The Allthing front page: trending Farcaster casts with their likes, recasts and replies, the app each one was posted from, and Allthing's poll](docs/screenshots/home.webp) +![Allthing's Following feed with For you and Following tabs, sorting and time ranges, Settings, the online count and Allthing's poll](docs/screenshots/home.webp)

A profile: bio, website, followers and following, join date, NS vote and wallet activity by chain @@ -114,7 +114,7 @@ bash scripts/check.sh # version, CSP hashes, changelog lin npx playwright install chromium # once npx playwright test '\.fixture\.spec\.ts$' # deterministic browser regressions, as CI runs them npx playwright test # everything, including live Farcaster data -E2E_BASE_URL=https://allthing.social npx playwright test # against production +E2E_BASE_URL=https://allthing.social E2E_EXPECT_WRITES_READY=true npx playwright test # production bash scripts/build-pages.sh && E2E_PAGES=1 npx playwright test # the static copy ``` diff --git a/crates/allthing-core/src/index.rs b/crates/allthing-core/src/index.rs index eee05e34..48bed0f6 100644 --- a/crates/allthing-core/src/index.rs +++ b/crates/allthing-core/src/index.rs @@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize}; use crate::model::Cast; use crate::normalize::{self, RawCast}; -use crate::ranking::{RECAST_WEIGHT, TrendingWindow, points}; +use crate::ranking::{RECAST_WEIGHT, TrendingWindow, may_ingest_timestamp, points}; use crate::time::parse_rfc3339; const DAY: i64 = 86_400; @@ -166,7 +166,7 @@ pub fn candidates(raw: &[RawCast], metas: &[RankingMeta], now: i64) -> Vec + RECAST_WEIGHT * count(reactions.and_then(|x| x.recasts_count)) + count(r.replies.as_ref().and_then(|x| x.count)); let ts = r.timestamp.as_deref().and_then(parse_rfc3339).unwrap_or(0); - points >= floor(metas, ts, now).min(NOTABLE_POINTS) + may_ingest_timestamp(ts, now) && points >= floor(metas, ts, now).min(NOTABLE_POINTS) }) .map(normalize::cast) .collect() @@ -179,7 +179,7 @@ pub fn worth_indexing(casts: &[Cast], metas: &[RankingMeta], now: i64) -> Vec= floor(metas, c.ts, now)) + .filter(|c| !c.is_reply() && !c.spam && may_ingest_timestamp(c.ts, now) && points(c) >= floor(metas, c.ts, now)) .filter(|c| seen.insert(c.hash.as_str())) .map(|c| Entry(c.hash.clone(), c.ts, points(c))) .collect() @@ -489,7 +489,8 @@ mod tests { {"hash":"0x02","timestamp":"2026-10-01T00:00:00Z","reactions":{"likes_count":5}}, {"hash":"0x03","parent_hash":"0x01","timestamp":"2026-10-01T00:00:00Z","reactions":{"likes_count":900}}, {"hash":"0x04","parent_hash":"","timestamp":"2026-10-01T00:00:00Z","reactions":{"likes_count":260}}, - {"hash":"0x05","timestamp":"2026-10-01T00:00:00Z","reactions":{"likes_count":100}} + {"hash":"0x05","timestamp":"2026-10-01T00:00:00Z","reactions":{"likes_count":100}}, + {"hash":"0xfuture","timestamp":"2061-09-12T00:03:58Z","reactions":{"likes_count":1000000}} ]"#, ) .unwrap(); @@ -501,6 +502,34 @@ mod tests { assert_eq!(hashes(candidates(&raw, &metas(400, 400, 600), now)), ["0x04"]); } + #[test] + fn only_small_clock_skew_is_worth_indexing_not_implausible_future_dates() { + let casts = vec![ + cast("0xskew", 1, NOW + 5, 30), + cast("0xfuture", 1, NOW + 6, 1_000_000), + cast("0xfar", 1, 2_893_718_638, 1_000_000), + cast("0xnow", 1, NOW, 30), + ]; + assert_eq!( + worth_indexing(&casts, &[], NOW), + [Entry("0xskew".into(), NOW + 5, 30), Entry("0xnow".into(), NOW, 30)] + ); + } + + #[test] + fn only_small_clock_skew_is_normalized_as_an_index_candidate() { + let raw: Vec = serde_json::from_str( + r#"[ + {"hash":"0xskew","timestamp":"2026-10-02T00:00:05Z","reactions":{"likes_count":30}}, + {"hash":"0xfuture","timestamp":"2026-10-02T00:00:06Z","reactions":{"likes_count":1000000}}, + {"hash":"0xfar","timestamp":"2061-09-12T00:03:58Z","reactions":{"likes_count":1000000}} + ]"#, + ) + .unwrap(); + let now = parse_rfc3339("2026-10-02T00:00:00Z").unwrap(); + assert_eq!(candidates(&raw, &[], now).iter().map(|cast| cast.hash.as_str()).collect::>(), ["0xskew"]); + } + #[test] fn notable_authors_come_best_first_once_each() { let casts = diff --git a/crates/allthing-core/src/latest.rs b/crates/allthing-core/src/latest.rs index 8a162b30..a4b6cca4 100644 --- a/crates/allthing-core/src/latest.rs +++ b/crates/allthing-core/src/latest.rs @@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize}; use crate::model::Cast; use crate::provenance::base64_decode; -use crate::ranking::TrendingWindow; +use crate::ranking::{TrendingWindow, may_ingest_timestamp}; /// The shards that hold users' messages (shard 0 holds none). pub const SHARDS: [u32; 2] = [1, 2]; @@ -154,13 +154,16 @@ pub fn to_hydrate(added: &[Added], listed: &[Cast], removed: &[String], limit: u } /// The newest `keep` casts of `listed` and `fresh` (newly read, so they -/// replace listed copies), without `removed` ones. -pub fn newest(listed: Vec, fresh: Vec, removed: &[String], keep: usize) -> Vec { +/// replace listed copies), without `removed` or implausible future dates. A +/// five-second source skew may stay in this existing buffer; serving is stricter. +/// Reject beyond that allowance before the cut so invalid dates cannot fill it. +pub fn newest(listed: Vec, fresh: Vec, removed: &[String], keep: usize, now: i64) -> Vec { let fresh_hashes: HashSet = fresh.iter().map(|c| c.hash.clone()).collect(); let mut all: Vec = fresh .into_iter() .chain(listed.into_iter().filter(|c| !fresh_hashes.contains(&c.hash))) .filter(|c| !removed.contains(&c.hash)) + .filter(|c| may_ingest_timestamp(c.ts, now)) .collect(); all.sort_by(|a, b| b.ts.cmp(&a.ts).then_with(|| a.hash.cmp(&b.hash))); all.truncate(keep); @@ -342,8 +345,16 @@ mod tests { fn keeps_the_newest_casts_with_fresh_counts_and_without_removed_ones() { let listed = vec![cast("0xa", 30, 1), cast("0xb", 20, 1), cast("0xc", 10, 1)]; let fresh = vec![cast("0xd", 40, 0), cast("0xb", 20, 7)]; - let kept = newest(listed, fresh, &["0xa".into()], 3); + let kept = newest(listed, fresh, &["0xa".into()], 3, 40); let summary: Vec<(&str, u64)> = kept.iter().map(|c| (c.hash.as_str(), c.likes)).collect(); assert_eq!(summary, [("0xd", 0), ("0xb", 7), ("0xc", 1)]); } + + #[test] + fn only_small_clock_skew_can_stay_in_the_existing_newest_buffer() { + let listed = vec![cast("0xoldfuture", 2_893_718_638, 999), cast("0xold", 20, 1)]; + let fresh = vec![cast("0xskew", 45, 999), cast("0xnewfuture", 46, 999), cast("0xnow", 40, 1)]; + let hashes: Vec<_> = newest(listed, fresh, &[], 3, 40).into_iter().map(|c| c.hash).collect(); + assert_eq!(hashes, ["0xskew", "0xnow", "0xold"]); + } } diff --git a/crates/allthing-core/src/ranking.rs b/crates/allthing-core/src/ranking.rs index 1570330b..fff5932b 100644 --- a/crates/allthing-core/src/ranking.rs +++ b/crates/allthing-core/src/ranking.rs @@ -7,6 +7,15 @@ use std::collections::BTreeSet; use crate::model::{Cast, ThreadNode}; +/// Allthing's ingest-only buffer for small source clock skew, preserving the +/// previously tested five-second case. This is not a protocol threshold and +/// never changes the strict present upper bound used when serving or drawing. +pub const INGEST_FUTURE_ALLOWANCE_SECS: i64 = 5; + +pub fn may_ingest_timestamp(ts: i64, now: i64) -> bool { + ts <= now.saturating_add(INGEST_FUTURE_ALLOWANCE_SECS) +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] pub enum Sort { #[default] @@ -189,10 +198,10 @@ impl TrendingWindow { /// Whether a cast posted at `ts` is inside the window at `now` (both in /// seconds since the Unix epoch, which has no time zone). The window /// starts just after `now − span`: a cast exactly that old is out, and - /// one a second newer is in. A cast newer than `now`, from a clock a - /// little ahead, is in. + /// one a second newer is in. Future-dated casts are out in every range, + /// including all time. A retained cast can become visible when `now` catches up. pub fn holds(self, ts: i64, now: i64) -> bool { - self.span_secs().is_none_or(|span| ts > now - span) + ts <= now && self.span_secs().is_none_or(|span| ts > now - span) } /// The window in words, for a note under the list. @@ -498,6 +507,7 @@ mod tests { (TrendingWindow::Day, 86_400), (TrendingWindow::Week, 604_800), (TrendingWindow::Month, 2_592_000), + (TrendingWindow::Year, 31_536_000), ] { assert_eq!(window.span_secs(), Some(span), "{window:?}"); assert!(!window.holds(now - span, now), "{window:?} exact boundary is out"); @@ -505,17 +515,33 @@ mod tests { assert!(!window.holds(now - span - 1, now), "{window:?} one second outside is out"); assert!(window.holds(now, now), "{window:?} now is in"); } - // A cast from a clock a little ahead is in, not dropped. - assert!(TrendingWindow::Hour.holds(now + 5, now)); + // No unbounded clock-skew allowance: even a slightly early cast waits + // until its timestamp is reached, rather than outranking the present. + for window in TrendingWindow::ALL { + for future in [now + 1, now + 5, 2_893_718_638, i64::MAX] { + assert!(!window.holds(future, now), "{window:?} future {future} is out"); + } + assert!(window.holds(now + 5, now + 5), "{window:?} eligible when the clock catches up"); + } } #[test] - fn a_window_for_all_time_holds_every_cast() { + fn a_window_for_all_time_has_no_lower_bound_but_excludes_the_future() { assert_eq!(TrendingWindow::All.span_secs(), None); assert!(TrendingWindow::All.holds(0, 1_792_000_000)); assert!(TrendingWindow::All.holds(i64::MIN / 2, 1_792_000_000)); } + #[test] + fn ingest_alone_keeps_five_seconds_of_source_skew_with_saturating_arithmetic() { + let now = 1_792_000_000; + assert!(may_ingest_timestamp(now + 5, now)); + assert!(!may_ingest_timestamp(now + 6, now)); + assert!(!may_ingest_timestamp(2_893_718_638, now)); + assert!(may_ingest_timestamp(i64::MAX, i64::MAX - 2)); + assert!(!TrendingWindow::All.holds(now + 5, now)); + } + #[test] fn a_window_is_plain_seconds_across_midnight_utc() { // 23:50 to 00:20 UTC, with the clock at 00:10: a one-hour window diff --git a/crates/allthing-data/src/indexer.rs b/crates/allthing-data/src/indexer.rs index 957b3494..2d88b895 100644 --- a/crates/allthing-data/src/indexer.rs +++ b/crates/allthing-data/src/indexer.rs @@ -207,7 +207,7 @@ pub(crate) mod tests { let casts = self.casts.borrow(); let mut inside: Vec = casts .iter() - .filter(|(_, (ts, _))| *ts >= cutoff) + .filter(|(_, (ts, _))| *ts >= cutoff && TrendingWindow::All.holds(*ts, (now_ms / 1000.0) as i64)) .map(|(h, (ts, p))| Entry(h.clone(), *ts, *p)) .collect(); let total = inside.len() as u64; diff --git a/crates/allthing-data/src/pages.rs b/crates/allthing-data/src/pages.rs index e9149c98..7c9cb785 100644 --- a/crates/allthing-data/src/pages.rs +++ b/crates/allthing-data/src/pages.rs @@ -315,7 +315,7 @@ fn now_secs(inv: &Invocation) -> i64 { (inv.now() / 1000.0) as i64 } -/// The casts posted within `window` at `now`; all of them for all time. +/// The casts posted within `window` at `now`; all time still excludes the future. fn within(window: TrendingWindow, now: i64, casts: Vec) -> Vec { casts.into_iter().filter(|cast| window.holds(cast.ts, now)).collect() } @@ -337,14 +337,13 @@ pub async fn home( // the poll's counts. let counted = async { if offset == 0 { apps_24h(&inv).await } else { None } }; let polled = async { if offset == 0 { poll_counts(&inv).await } else { None } }; - let now = now_secs(&inv); if let Some(span) = Span::of(window) && let Some(ranking) = ranking(&inv, span).await { let hashes = ranking.page(offset as usize, POOL_SIZE as usize); let (ranked, counted, poll) = join3(ranked_casts(&inv, &hashes), counted, polled).await; let (raw, casts) = ranked?; - let casts = within(window, now, casts); + let casts = within(window, now_secs(&inv), casts); let pool = drop_hidden(&inv, casts, &mut notes, &known_links, true).await; let quoted = annotate_listing(&inv, &mut notes, &pool, ORIGIN_AUTHORS, &known_links).await; notes.keep_users(read_cards(&raw, quoted), listed_accounts(&pool, ¬es.quotes)); @@ -366,6 +365,9 @@ pub async fn home( let ((trending, newest), counted, poll) = join3(join(hypersnap::trending_casts(&inv, POOL_SIZE, offset, shown), newest), counted, polled).await; let raw = trending.map_err(Failure::upstream)?; + // A source read can include casts posted after this request began. + // Judge both edges at its completion, not before awaiting the response. + let now = now_secs(&inv); // Hypersnap's list can hold casts older than the window (its 12-hour // list is its 24-hour one), so only those posted within it are kept, // before the page is cut. A page short of casts is not topped up with @@ -417,6 +419,11 @@ pub async fn following( raw.extend(more); next = after; } + // Keep the existing page-read decision: validation must not fetch another + // upstream page merely to replace rows with invalid future timestamps. + // Use the current clock after both reads: otherwise an actually present + // cast created during I/O could be dropped while its cursor advances. + let theirs = within(TrendingWindow::All, now_secs(&inv), theirs); let pool = drop_hidden(&inv, theirs, &mut notes, &known_links, true).await; let quoted = annotate_listing(&inv, &mut notes, &pool, ORIGIN_AUTHORS, &known_links).await; notes.keep_users(read_cards(&raw, quoted), listed_accounts(&pool, ¬es.quotes)); @@ -503,12 +510,23 @@ pub async fn latest(inv: Rc, previous: Option>>) -> Resul && warm.as_ref().is_none_or(|p| stored.built > built_at(p)) { if inv.now() - stored.built < SNAPSHOT_SERVE_MS { - let body = stored.body.into_bytes(); + let mut payload: LatestPayload = serde_json::from_str(&stored.body) + .map_err(|e| Failure::internal(&format!("invalid newest-casts snapshot: {e}")))?; + let before = payload.pool.len(); + payload.pool.retain(|cast| TrendingWindow::All.holds(cast.ts, now_secs(&inv))); + // Preserve the exact stored bytes on the common unchanged path. + let body = if payload.pool.len() == before { + stored.body.into_bytes() + } else { + serde_json::to_vec(&payload).map_err(|e| Failure::internal(&e.to_string()))? + }; return Ok(Built { body, status: 200, policy: api::LATEST_CACHE, fetched_at: Some(stored.built) }); } warm = serde_json::from_str(&stored.body).ok(); } - build_latest(&inv, warm).await + let mut payload = build_latest(&inv, warm).await?; + payload.pool.retain(|cast| TrendingWindow::All.holds(cast.ts, now_secs(&inv))); + Built::json(&payload, api::LATEST_CACHE) } /// The cron's turn: the newest casts, built on the stored copy and stored @@ -517,8 +535,10 @@ pub async fn latest(inv: Rc, previous: Option>>) -> Resul pub async fn store_latest(inv: Rc) -> Result<(), Failure> { let index = inv.index().ok_or_else(|| Failure::internal("no index to store the newest casts in"))?; let warm = stored(&inv, LATEST_SNAPSHOT).await.and_then(|s| serde_json::from_str(&s.body).ok()); - let built = build_latest(&inv, warm).await?; - let body = String::from_utf8(built.body).map_err(|e| Failure::internal(&e.to_string()))?; + // Keep the bounded ingest buffer for the cron; the public `latest` view + // excludes every future timestamp, without mutating this stored source. + let payload = build_latest(&inv, warm).await?; + let body = serde_json::to_string(&payload).map_err(|e| Failure::internal(&e.to_string()))?; if !inv.take_subrequests(1) { return Err(Failure::internal("no subrequest left to store the newest casts")); } @@ -551,7 +571,7 @@ async fn stored(inv: &Invocation, name: &'static str) -> Option { index.snapshot(name).await.ok().flatten() } -async fn build_latest(inv: &Invocation, warm: Option) -> Result { +async fn build_latest(inv: &Invocation, warm: Option) -> Result { let warm = warm.filter(|p| inv.now() - (p.meta.fetched_at as f64) < WARM_MS); let (listed, cursors, mut notes) = match warm { Some(p) => (p.pool, p.cursors, p.notes), @@ -595,10 +615,10 @@ async fn build_latest(inv: &Invocation, warm: Option) -> Result>, stored: Option>, + now_ms: Option, } /// The part of the index the newest casts use: the cron's stored copy. @@ -1568,7 +1589,7 @@ mod tests { impl Host for Hub { fn now(&self) -> f64 { - 1_800_000_000_000.0 + self.now_ms.unwrap_or(1_800_000_000_000.0) } fn fetch(&self, request: HttpRequest) -> LocalBoxFuture<'static, Result> { @@ -1731,6 +1752,49 @@ mod tests { assert!(hub.requests.borrow().is_empty(), "no upstream calls: {:?}", hub.requests.borrow()); } + #[test] + fn a_fresh_stored_latest_copy_excludes_future_rows_without_more_reads() { + let stored = stored_copy(40_000.0); + let before; + { + let mut copy = stored.copy.borrow_mut(); + let snapshot = copy.as_mut().unwrap(); + let mut payload: LatestPayload = serde_json::from_str(&snapshot.body).unwrap(); + let present: Cast = serde_json::from_value(serde_json::json!({ + "hash": "0xpresent", "author": { "fid": 7, "username": "seven" }, "text": "present", + "ts": (NOW / 1000.0) as i64, "likes": 1 + })) + .unwrap(); + payload.pool.push(present.clone()); + before = payload.pool.len(); + let mut future = present; + future.hash = "0xfuture".into(); + future.ts = (NOW / 1000.0) as i64 + 5; + payload.pool.insert(0, future); + snapshot.body = serde_json::to_string(&payload).unwrap(); + } + let hub = Rc::new(Hub { stored: Some(stored.clone()), ..Default::default() }); + let inv = Rc::new(Invocation::new(hub.clone(), 45)); + let built = latest(inv, None).now_or_never().unwrap().unwrap(); + let payload: LatestPayload = serde_json::from_slice(&built.body).unwrap(); + assert_eq!(payload.pool.len(), before); + assert!(payload.pool.iter().all(|cast| cast.ts <= (NOW / 1000.0) as i64)); + assert_eq!(built.fetched_at, Some(NOW - 40_000.0)); + assert_eq!(stored.reads.get(), 1); + assert!(hub.requests.borrow().is_empty()); + // The source copy still holds the small skew. A later public read can + // expose it, if retained, without re-reading the event source or aging it anew. + let later = Rc::new(Hub { stored: Some(stored.clone()), now_ms: Some(NOW + 5_000.0), ..Default::default() }); + let inv = Rc::new(Invocation::new(later.clone(), 45)); + let built = latest(inv, None).now_or_never().unwrap().unwrap(); + let payload: LatestPayload = serde_json::from_slice(&built.body).unwrap(); + assert_eq!(payload.pool.len(), before + 1); + assert!(payload.pool.iter().any(|cast| cast.hash == "0xfuture")); + assert_eq!(built.fetched_at, Some(NOW - 40_000.0)); + assert_eq!(stored.reads.get(), 2, "one existing snapshot read for each public request"); + assert!(later.requests.borrow().is_empty()); + } + #[test] fn an_older_copy_of_the_crons_is_built_on_from_its_cursors() { let mut hub = Hub { stored: Some(stored_copy(120_000.0)), ..Default::default() }; @@ -1774,6 +1838,54 @@ mod tests { assert_eq!(payload.pool.iter().map(|c| c.hash.as_str()).collect::>(), ["0xd3"]); assert_eq!(payload.cursors, [Cursor { shard: 1, event_id: 13 }, Cursor { shard: 2, event_id: 22 }]); } + + #[test] + fn only_the_crons_existing_buffer_retains_a_small_source_clock_skew() { + let now = allthing_core::time::parse_rfc3339("2026-10-02T00:00:00Z").unwrap() as f64 * 1000.0; + let stored = Rc::new(Stored::default()); + let mut hub = Hub { stored: Some(stored.clone()), now_ms: Some(now), ..Default::default() }; + hub.events.insert( + ("1".into(), "newest".into()), + page(&[ + add(12, "0xnow", 100, false), + add(13, "0xskew", 105, false), + add(14, "0xfuture", 106, false), + add(15, "0xfar", 107, false), + ]), + ); + hub.events.insert(("2".into(), "newest".into()), page(&[])); + for (hash, timestamp) in [ + ("0xnow", "2026-10-02T00:00:00Z"), + ("0xskew", "2026-10-02T00:00:05Z"), + ("0xfuture", "2026-10-02T00:00:06Z"), + ("0xfar", "2061-09-12T00:03:58Z"), + ] { + hub.casts.insert(hash.into(), raw_cast(hash, timestamp, 1)); + } + let public = build( + Rc::new(Hub { + events: hub.events.clone(), + casts: hub.casts.clone(), + now_ms: Some(now), + ..Default::default() + }), + None, + ) + .unwrap(); + assert_eq!(public.pool.iter().map(|cast| cast.hash.as_str()).collect::>(), ["0xnow"]); + + let hub = Rc::new(hub); + let inv = Rc::new(Invocation::new(hub.clone(), 45)); + store_latest(inv).now_or_never().unwrap().unwrap(); + let saved = stored.copy.borrow().clone().unwrap(); + let payload: LatestPayload = serde_json::from_str(&saved.body).unwrap(); + assert_eq!(payload.pool.iter().map(|cast| cast.hash.as_str()).collect::>(), ["0xskew", "0xnow"]); + assert_eq!(payload.cursors, [Cursor { shard: 1, event_id: 15 }]); + assert_eq!(stored.reads.get(), 1); + assert_eq!(hub.requests.borrow().iter().filter(|url| url.contains("/v1/events?")).count(), 2); + assert_eq!(hub.requests.borrow().iter().filter(|url| url.contains("/v2/farcaster/casts?")).count(), 1); + assert_eq!(saved.built, now); + } } #[cfg(test)] @@ -2380,6 +2492,7 @@ mod indexed_home_tests { /// Hypersnap's trending list of casts posted at fixed times, and no index. struct Trending { casts: Vec<(&'static str, &'static str)>, + next: Option<&'static str>, requests: RefCell>, } @@ -2393,7 +2506,7 @@ mod indexed_home_tests { fn fetch(&self, request: HttpRequest) -> LocalBoxFuture<'static, Result> { let url = request.url.clone(); self.requests.borrow_mut().push(url.clone()); - let body = if url.contains("/feed/trending?") { + let body = if url.contains("/feed/trending?") || url.contains("/feed/following?") { let casts: Vec<_> = self .casts .iter() @@ -2401,7 +2514,7 @@ mod indexed_home_tests { json!({ "hash": hash, "author": { "fid": 2, "username": "two" }, "text": "words", "timestamp": timestamp }) }) .collect(); - json!({ "casts": casts }) + json!({ "casts": casts, "next": { "cursor": self.next } }) } else { json!({}) }; @@ -2415,6 +2528,69 @@ mod indexed_home_tests { } } + /// Two seconds pass while the source response arrives, including a cast + /// written during the read and one still a second ahead at its end. + struct Advancing { + feed: Trending, + now: Rc>, + } + + impl Advancing { + fn new() -> Rc { + Rc::new(Self { + feed: Trending { + casts: vec![ + ("0xduring", "2026-10-14T17:46:41Z"), + ("0xfuture", "2026-10-14T17:46:43Z"), + ("0xaged", "2026-10-14T16:46:41Z"), + ], + next: Some("42"), + requests: RefCell::new(Vec::new()), + }, + now: Rc::new(std::cell::Cell::new(NOW_MS)), + }) + } + } + + impl Host for Advancing { + fn now(&self) -> f64 { + self.now.get() + } + + fn spawn(&self, _: LocalBoxFuture<'static, ()>) {} + + fn fetch(&self, request: HttpRequest) -> LocalBoxFuture<'static, Result> { + let read = self.feed.fetch(request); + let now = self.now.clone(); + async move { + let result = read.await; + now.set(NOW_MS + 2_000.0); + result + } + .boxed_local() + } + } + + #[test] + fn home_filters_at_the_time_the_source_read_completes() { + let host = Advancing::new(); + // The one-hour window advances too: 0xaged was inside it when the + // request began, but is outside when the source response arrives. + assert_eq!(hashes(&build(&host, 0, TrendingWindow::Hour)), ["0xduring"]); + assert_eq!(host.feed.requests.borrow().iter().filter(|url| url.contains("/feed/trending?")).count(), 1); + } + + #[test] + fn following_keeps_casts_created_while_its_source_read_is_in_flight() { + let host = Advancing::new(); + let inv = Rc::new(Invocation::new(host.clone(), 45)); + let built = following(inv, 7, Some("later".into()), None).now_or_never().unwrap().unwrap(); + let payload: FollowingPayload = serde_json::from_slice(&built.body).unwrap(); + assert_eq!(payload.pool.iter().map(|cast| cast.hash.as_str()).collect::>(), ["0xduring", "0xaged"]); + assert_eq!(payload.next.as_deref(), Some("42")); + assert_eq!(host.feed.requests.borrow().iter().filter(|url| url.contains("/feed/following?")).count(), 1); + } + #[test] fn each_window_keeps_only_the_casts_posted_within_it() { // NOW_MS is 14 October 2026, 17:46:40 UTC: 30 minutes, 2 hours, 5 hours and 3 days before. @@ -2424,7 +2600,10 @@ mod indexed_home_tests { ("0xa2", "2026-10-14T15:46:40Z"), ("0xa3", "2026-10-14T12:46:40Z"), ("0xa4", "2026-10-11T17:46:40Z"), + ("0xf1", "2026-10-14T17:46:41Z"), + ("0xf2", "2061-09-12T00:03:58Z"), ], + next: None, requests: RefCell::new(Vec::new()), }); let shown = |window| build(&host, 0, window).pool.into_iter().map(|cast| cast.hash).collect::>(); @@ -2443,6 +2622,24 @@ mod indexed_home_tests { assert!(!asked.iter().any(|url| url.contains("time_window=12h")), "{asked:?}"); } + #[test] + fn following_payloads_exclude_future_casts_without_changing_page_reads() { + let host = Rc::new(Trending { + casts: vec![ + ("0xpresent", "2026-10-14T17:46:40Z"), + ("0xfuture", "2026-10-14T17:46:41Z"), + ("0xfar", "2061-09-12T00:03:58Z"), + ], + next: None, + requests: RefCell::new(Vec::new()), + }); + let inv = Rc::new(Invocation::new(host.clone(), 45)); + let built = following(inv, 7, Some("later".into()), None).now_or_never().unwrap().unwrap(); + let payload: FollowingPayload = serde_json::from_slice(&built.body).unwrap(); + assert_eq!(payload.pool.iter().map(|cast| cast.hash.as_str()).collect::>(), ["0xpresent"]); + assert_eq!(host.requests.borrow().iter().filter(|url| url.contains("/feed/following?")).count(), 1); + } + #[test] fn the_first_page_carries_the_polls_counts_once_someone_has_voted() { let counts = Counts { yes: 3, no: 1 }; diff --git a/crates/allthing-web/src/components/emoji_picker.rs b/crates/allthing-web/src/components/emoji_picker.rs index aab4eda3..ef54255b 100644 --- a/crates/allthing-web/src/components/emoji_picker.rs +++ b/crates/allthing-web/src/components/emoji_picker.rs @@ -365,10 +365,15 @@ pub fn EmojiPicker( current.set(i); } }; - // A group not drawn yet is drawn now, with the rest, and gone to in - // the next frame. + // An already drawn group can still be at the temporary bottom, with + // too little trailing content to reach the top. Draw the rest before + // jumping when the heading is missing or would be clamped there. + let can_align = match (results.get_untracked(), headings().get(i)) { + (Some(results), Some(heading)) => heading.offset_top() <= results.scroll_height() - results.client_height(), + _ => false, + }; match drawing.get_value() { - Some(drawn) if drawn.try_get_untracked().is_some_and(|groups| groups <= i) => { + Some(drawn) if drawn.try_get_untracked().is_some_and(|groups| groups < Group::ALL.len()) && !can_align => { let _ = drawn.try_set(Group::ALL.len()); request_animation_frame(go); } diff --git a/crates/allthing-web/src/components/quote.rs b/crates/allthing-web/src/components/quote.rs index b5173a94..9306053d 100644 --- a/crates/allthing-web/src/components/quote.rs +++ b/crates/allthing-web/src/components/quote.rs @@ -75,7 +75,8 @@ pub fn QuoteCard( let body_cast = cast.clone(); let body = move || { let cast = &body_cast; - if cast.hidden_for(crate::miniapps::shown()) { + let shown = !cast.mini_app || crate::miniapps::shown(); + if cast.hidden_for(shown) { // No label on the cast itself: a mini app is a format, said as such. let note = if cast.spam { "Quoted cast hidden" } else { "Quoted cast hidden: mini app" }; view! {

{note}

}.into_any() diff --git a/crates/allthing-web/src/pages/home.rs b/crates/allthing-web/src/pages/home.rs index 10350970..23db731e 100644 --- a/crates/allthing-web/src/pages/home.rs +++ b/crates/allthing-web/src/pages/home.rs @@ -383,23 +383,26 @@ fn Trending(sort: Memo, listed: ReadSignal, range: Memo) let mut leanings = leaning.for_list(sorted_by, (order, filter)); // For you: the casts of whom the reader follows lifted. leanings.followed = followed.get_value(); - let mut all = sorted_for(&pool, order, &leanings); + // Cached out-of-range or future casts must not inflate + // span_days and distort the remaining casts' leaning weights. + let now = now_secs(); + let within = |casts: &[Cast]| -> Vec { + casts.iter().filter(|cast| shown.holds(cast.ts, now)).cloned().collect() + }; + let mut all = sorted_for(&within(&pool), order, &leanings); more.with(|pages| { for page in pages { all.extend( - sorted_for(&page.casts, order, &leanings) + sorted_for(&within(&page.casts), order, &leanings) .into_iter() .filter(|c| !first_hashes.contains(&c.hash)), ); } }); - let all = match filter { + match filter { Some(fid) => from_client(all, ¬es.get(), fid), None => all, - }; - // A copy from the edge, or the app's own, can be older than - // its window: what has aged out since is left out here too. - all.into_iter().filter(|cast| shown.holds(cast.ts, now_secs())).collect() + } }); let chip_notes = Arc::new(payload.notes.clone()); let in_window: Vec = @@ -466,7 +469,8 @@ fn Following(sort: Memo, listed: ReadSignal, range: Memo) } match result { Ok(page) => { - if let Some(newest) = page.data.pool.iter().map(|c| c.ts).max() { + let now = now_secs(); + if let Some(newest) = page.data.pool.iter().filter(|c| c.ts <= now).map(|c| c.ts).max() { tail.set(Some(newest)); } let mut known: std::collections::HashSet = more @@ -514,7 +518,10 @@ fn Following(sort: Memo, listed: ReadSignal, range: Memo) crate::followed::remember(fid, &payload); } next.set(payload.next.clone()); - tail.set(payload.pool.iter().map(|c| c.ts).max()); + // Future dates cannot establish that the source page is already + // older than the window; an invalid-only page behaves like an empty one. + let now = now_secs(); + tail.set(payload.pool.iter().filter(|c| c.ts <= now).map(|c| c.ts).max()); let first_notes = payload.notes.clone(); let notes = Memo::new(move |_| { let mut notes = first_notes.clone(); diff --git a/crates/allthing-web/src/pages/post.rs b/crates/allthing-web/src/pages/post.rs index 7ccda92c..3609ff9c 100644 --- a/crates/allthing-web/src/pages/post.rs +++ b/crates/allthing-web/src/pages/post.rs @@ -145,12 +145,13 @@ fn PostPage() -> impl IntoView { Some(Some(Err(failure))) => view! { }.into_any(), Some(Some(Ok(Fetched { data: payload, stale, .. }))) => { let payload: Arc = payload; - // The reader's choice about mini apps: their replies and ancestors pruned - // as they chose, the page drawn again when it changes. - let show_apps = crate::miniapps::shown(); + // Only a thread with mini app replies or ancestors needs + // rebuilding when that preference changes. Ordinary posts + // keep their body, media and composer mounted. let payload = if allthing_core::thread::flatten(&payload.thread).iter().skip(1).any(|c| c.mini_app) || payload.ancestors.iter().any(|c| c.mini_app) { + let show_apps = crate::miniapps::shown(); let mut thread = payload.thread.clone(); thread.children = std::mem::take(&mut thread.children) .into_iter() @@ -296,12 +297,7 @@ async fn newer_replies(hash: &str, want: u64) { } #[component] -fn MainCast(mut cast: Cast, notes: Notes, payload: Arc) -> impl IntoView { - // A mini app cast, for a reader who leaves them out: who and when, and - // that it is one (spam comes emptied already). - if crate::miniapps::hides(&cast) { - cast.strip_hidden(); - } +fn MainCast(cast: Cast, notes: Notes, payload: Arc) -> impl IntoView { // Whether the reader liked and recasted this cast, which may be older // than their latest likes and recasts. #[cfg(not(feature = "static-host"))] @@ -319,6 +315,41 @@ fn MainCast(mut cast: Cast, notes: Notes, payload: Arc) -> impl Int let href = user_href(&author.username); let gone = crate::components::deleted(&cast.hash); let (pfp, name, username) = (author.pfp_url.clone(), author.name().to_owned(), author.username.clone()); + // Keep the carried content intact so Settings can reveal it again. Only + // mini app bodies track this preference; ordinary posts keep their media. + let body_cast = cast.clone(); + let body_notes = notes.clone(); + let body_payload = payload.clone(); + let body = move || { + let cast = &body_cast; + let shown = !cast.mini_app || crate::miniapps::shown(); + if cast.hidden_for(shown) { + let note = if cast.spam { + "Post hidden: Allthing leaves out posts with links to vercel.app apps, gig-bot, DEGEN reward or airdrop bait, and DEGEN staking promotions." + } else { + "Mini app post hidden: Allthing does not run mini apps or frames." + }; + view! { +

{note}

+ {(!cast.spam).then(|| view! { })} + } + .into_any() + } else { + view! { +
+ + + + {body_payload.quotes.iter().map(|quote| match "e.cast { + Some(q) => view! { + + }.into_any(), + None => view! { "↳ quoted cast unavailable" }.into_any(), + }).collect_view()} + } + .into_any() + } + }; view! {

"You deleted this cast."

@@ -334,30 +365,8 @@ fn MainCast(mut cast: Cast, notes: Notes, payload: Arc) -> impl Int - {if crate::miniapps::hides(&cast) { - let note = if cast.spam { - "Post hidden: Allthing leaves out posts with links to vercel.app apps, gig-bot, DEGEN reward or airdrop bait, and DEGEN staking promotions." - } else { - "Mini app post hidden: Allthing does not run mini apps or frames." - }; - view! { -

{note}

- {(!cast.spam).then(|| view! { })} - } - .into_any() - } else { - view! {
}.into_any() - }} - - - + {body} - {payload.quotes.iter().map(|quote| match "e.cast { - Some(q) => view! { - - }.into_any(), - None => view! { "↳ quoted cast unavailable" }.into_any(), - }).collect_view()}

A cold-load diagnostic fixture

' })); + await page.route("**/__perfdiag.css?*", (route) => route.fulfill({ contentType: "text/css", body: "body{margin:0}img{display:block}" })); + await page.route("**/__perfdiag.png?*", (route) => route.fulfill({ contentType: "image/png", body: readFileSync("web/pfp.png") })); + await page.route("**/api/v1/home?*", (route) => route.fulfill({ json: { pool: [] }, headers: { "x-allthing-cache": "fresh", "cf-cache-status": "HIT", "cf-ray": "fixture-BEG", "server-timing": 'fresh;dur=3;desc="https://user:private@example.com/?header-secret;dur=999,private", backend;dur=4' } })); + await page.goto("/__perfdiag?document-secret"); + await page.evaluate(async () => { await (await fetch("/api/v1/home?api-secret")).json(); }); + await expect.poll(() => page.evaluate(() => (window as any).__allthingPerfDiagnostics.lcp?.element?.tag), { timeout: 5000 }).toBe("IMG"); + // A real main-thread pause: its observation must survive in the report. + await page.evaluate(() => { setTimeout(() => { const end = performance.now() + 80; while (performance.now() < end) {} }, 0); }); + await expect.poll(() => page.evaluate(() => (window as any).__allthingPerfDiagnostics.longtasks.length), { timeout: 5000 }).toBeGreaterThan(0); + const result = await readPerfDiagnostics(page, responses); + expect(result.lcp.time).toBeGreaterThan(0); + expect(result.appCast).toBeGreaterThan(0); + expect(result.lcp.element.tag).toBe("IMG"); + expect(result.lcpResource?.url).toMatch(/\/__perfdiag\.png$/); + expect(result.navigation.response).toBeGreaterThan(0); + expect(result.resources.some((resource) => resource.url.endsWith("/api/v1/home") && resource.end >= resource.start)).toBe(true); + expect(result.responses.find((response) => response.url.endsWith("/api/v1/home"))).toMatchObject({ cache: "fresh", cfCache: "HIT", colo: "BEG", serverTiming: [{ name: "fresh", duration: 3 }, { name: "backend", duration: 4 }] }); + expect(result.longtasks.some((task: { duration: number }) => task.duration >= 50)).toBe(true); + const serialized = JSON.stringify(result); + expect(serialized).not.toMatch(/secret|password|private|reader:/); +}); diff --git a/e2e/perf.spec.ts b/e2e/perf.spec.ts index 5c72346d..c1c487ba 100644 --- a/e2e/perf.spec.ts +++ b/e2e/perf.spec.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import { expect, test, type Page } from "@playwright/test"; +import { perfDiagnosticsProbe, perfResponses, readPerfDiagnostics } from "./helpers/perf-diagnostics"; /* * Performance budget (docs/PERFORMANCE.md), measured in the browser: @@ -47,20 +48,40 @@ async function lcp(page: Page): Promise { } test.describe("performance budget", () => { - test("home LCP stays under 1.5 s (cold, median of 3)", async ({ browser }) => { + test("home LCP stays under 1.5 s (cold, median of 3)", async ({ browser }, testInfo) => { const samples: number[] = []; - for (let i = 0; i < 3; i++) { - const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); - const page = await context.newPage(); - await page.addInitScript(firstCastProbe); - await page.goto("/"); - await expect(page.locator("#app .cast-card:not(.skeleton)").first()).toBeVisible(); - await page.waitForTimeout(300); - // The page's largest content is the feed: count it from when the first - // cast appears, even if the browser's LCP entry picked something earlier. - const firstCast = await page.evaluate(() => (window as any).__firstCast as number); - samples.push(Math.max(await lcp(page), firstCast)); - await context.close(); + const diagnostics: unknown[] = []; + try { + for (let i = 0; i < 3; i++) { + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + try { + const page = await context.newPage(); + const responses = perfResponses(page); + await page.addInitScript(firstCastProbe); + await page.addInitScript(perfDiagnosticsProbe); + try { + await page.goto("/"); + await expect(page.locator("#app .cast-card:not(.skeleton)").first()).toBeVisible(); + await page.waitForTimeout(300); + // Keep the existing budget: the later of browser LCP and first cast. + const firstCast = await page.evaluate(() => (window as any).__firstCast as number); + const browserLcp = await lcp(page); + const value = Math.max(browserLcp, firstCast); + samples.push(value); + const detail = { sample: i + 1, value, firstCast, browserLcp, ...await readPerfDiagnostics(page, responses) }; + diagnostics.push(detail); + console.log(`LCP diagnostic ${JSON.stringify(detail)}`); + } catch (error) { + // Capture the last pipeline state even if readiness itself fails. + const detail = { sample: i + 1, incomplete: true, ...await readPerfDiagnostics(page, responses).catch(() => ({ responses })) }; + diagnostics.push(detail); + console.log(`LCP diagnostic ${JSON.stringify(detail)}`); + throw error; + } + } finally { await context.close(); } + } + } finally { + await testInfo.attach("home-lcp-diagnostics.json", { body: JSON.stringify({ budget: LCP_BUDGET, samples: diagnostics }, null, 2), contentType: "application/json" }); } const value = median(samples); console.log(`LCP samples ${samples.map((s) => s.toFixed(0)).join(", ")} ms; median ${value.toFixed(0)} ms`); diff --git a/e2e/presence-worker.fixture.spec.ts b/e2e/presence-worker.fixture.spec.ts index 5e32496d..0d73291c 100644 --- a/e2e/presence-worker.fixture.spec.ts +++ b/e2e/presence-worker.fixture.spec.ts @@ -46,6 +46,8 @@ let state = ""; const printed: string[] = []; /** What the stand-in node was asked. */ const asked: string[] = []; +/** A response finishes after any edge-cache write that beat requested. */ +let lastBeatAt = 0; /** A session cookie as the Worker's sign-in sets it. */ function session(fid: number) { @@ -67,7 +69,9 @@ async function beat(body: object | string, { cookie = "", origin = ORIGIN as str if (origin !== null) headers.origin = origin; if (cookie) headers.cookie = cookie; const response = await fetch(`${ORIGIN}/api/v1/presence`, { method: "POST", headers, body: typeof body === "string" ? body : JSON.stringify(body) }); - return { status: response.status, cache: response.headers.get("cache-control"), json: await response.json() }; + const json = await response.json(); + lastBeatAt = Date.now(); + return { status: response.status, cache: response.headers.get("cache-control"), json }; } /** A query on the Worker's local D1. Not run synchronously: that would @@ -133,13 +137,12 @@ test.afterAll(async () => { }); test.describe("on", () => { - /** When this location last counted. */ - let counted = 0; - /** Wait until the next beat recounts. */ + /** A public or visibility-changing beat may refresh the cached answer. + * Wait from the latest response, a safe upper bound on that cache write, + * rather than from the first count before those later beats arrived. */ const recount = async () => { - const wait = counted + RECOUNT_MS - Date.now(); + const wait = lastBeatAt + RECOUNT_MS - Date.now(); if (wait > 0) await new Promise((done) => setTimeout(done, wait)); - counted = Date.now(); }; test.beforeAll(async () => { @@ -153,7 +156,6 @@ test.describe("on", () => { const [a, b, signedIn, forged, readable] = [id(), id(), id(), id(), id()]; // The first beat counts: one here, no one named, beat again in four minutes. - counted = Date.now(); const first = await beat({ id: a, hidden: false }); expect(first.status).toBe(200); expect(first.cache).toBe("no-store"); @@ -165,7 +167,12 @@ test.describe("on", () => { // location counted a moment ago.) expect((await beat({ id: b })).status).toBe(200); // A signed-in reader, named by their session cookie. - expect((await beat({ id: signedIn }, { cookie: session(reader.fid) })).status).toBe(200); + // The public beat starts a new edge-cache window even when earlier + // beats were already counted. Keep it away from that first boundary. + await new Promise((done) => setTimeout(done, 2_000)); + const named = await beat({ id: signedIn }, { cookie: session(reader.fid) }); + expect(named.status).toBe(200); + expect(named.json.online).toBe(3); // Never by what the browser says: a fid in the beat, or the readable user cookie. expect((await beat({ id: forged, fid: 7, username: "forged" })).status).toBe(200); const userCookie = `__Host-allthing-user=${encodeURIComponent(JSON.stringify({ fid: 8, username: "claimed" }))}`; diff --git a/e2e/seen-posts.fixture.spec.ts b/e2e/seen-posts.fixture.spec.ts index 10bf2905..e099e330 100644 --- a/e2e/seen-posts.fixture.spec.ts +++ b/e2e/seen-posts.fixture.spec.ts @@ -58,6 +58,29 @@ for (const width of [1280, 390]) { }); } +test("a sort paints its selected tab before suppressing the previous generation's read cards", async ({ page }) => { + await serve(page); + await page.goto("/"); + await expect(page.locator("#app .cast-card[data-hash]")).toHaveCount(25); + await expect.poll(() => hashes(page)).toContain(hash(1)); + const firstFrame = await page.evaluate(() => new Promise<{ retained: boolean; selected: boolean; count: number }>((resolve) => { + const old = document.querySelector("#app .cast-card[data-hash]")!; + const tab = document.querySelector('#app .sort-tabs a[href*="sort=top"]')!; + // Queue this before the router handles the click. The first animation + // frame observes the tap's answer; the old list's cleanup comes later. + tab.addEventListener("click", () => requestAnimationFrame(() => resolve({ + retained: old.isConnected, + selected: document.querySelector("#app .sort-tabs a.selected")?.textContent === "top", + count: document.querySelectorAll("#app .cast-card[data-hash]").length, + })), { once: true, capture: true }); + tab.click(); + })); + expect(firstFrame).toEqual({ retained: true, selected: true, count: 25 }); + // Deferring cleanup still applies reading history to the next generation. + await expect(card(page, 1)).toHaveCount(0); + await expect(page.getByRole("button", { name: "Show seen posts", exact: true })).toBeVisible(); +}); + test("a card taller than two screens counts after a full viewport dwell, not from a sliver", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); // Enough preceding rows to place the card below 800 document pixels: diff --git a/e2e/show-less.fixture.spec.ts b/e2e/show-less.fixture.spec.ts index 8b5482af..53a804eb 100644 --- a/e2e/show-less.fixture.spec.ts +++ b/e2e/show-less.fixture.spec.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import { expect, test, type Page } from "@playwright/test"; import { signIn } from "./helpers/farcaster"; +import { showSeenPosts } from "./helpers/app"; import { NOW } from "./fixture-time"; /* @@ -16,7 +17,8 @@ import { NOW } from "./fixture-time"; test.skip(Boolean(process.env.E2E_PAGES), "uses the Worker API contract"); const meta = { backend: "hypersnap", fetched_at: Date.now() }; -const ts = (NOW - 19); +// Leave room for Newest below: every fixture cast is in the past 24 hours. +const ts = NOW - 3 * 3600; const hex = (n: number) => `0x${n.toString(16).padStart(40, "0")}`; const person = (fid: number, username: string) => ({ fid, username, display_name: username[0].toUpperCase() + username.slice(1), follower_count: 10, following_count: 5, bio: "" }); const ann = person(1, "ann"); @@ -51,6 +53,7 @@ const order = (page: Page) => async function feed(page: Page, path: string, expected: string[]) { await page.goto(path); + await showSeenPosts(page); await expect(page.locator(".cast-card:not(.skeleton)").first()).toBeVisible(); await expect.poll(() => order(page)).toEqual(expected); } diff --git a/e2e/time-filter.fixture.spec.ts b/e2e/time-filter.fixture.spec.ts index bef99b9d..fca8d378 100644 --- a/e2e/time-filter.fixture.spec.ts +++ b/e2e/time-filter.fixture.spec.ts @@ -40,6 +40,14 @@ async function serve(page: Page, { index = true } = {}) { const coverage = indexed.includes(window) ? { index: { casts: 12345, since, ranked_at: Date.now() } } : {}; return route.fulfill({ json: { pool: offset ? [] : pool(window), meta, ...coverage } }); }); + await page.route("**/api/v1/latest", async (route) => { + calls.push("/api/v1/latest"); + // New reads its own source. Keep it delayed and distinguishable from + // the previous trending rows, so carried controls cannot pass the test. + await new Promise((done) => setTimeout(done, 100)); + const newest = pool("7d").map((cast, i) => ({ ...cast, hash: `0x${"7".repeat(39)}${i}`, text: `Newest cast ${i}` })); + return route.fulfill({ json: { pool: newest, notes: {}, cursors: [], meta } }); + }); return calls; } @@ -94,6 +102,11 @@ test("the time filter picks the window and is kept in the URL", async ({ page }) await page.getByRole("navigation", { name: "Sort", exact: true }).getByRole("link", { name: "new", exact: true }).click(); await expect(page).toHaveURL(/[?&]t=week/); await expect(page).toHaveURL(/[?&]sort=new/); + // Wait for New's own payload, rather than checking the carried range + // before an unhandled source response can replace the previous view. + await expect(cards.first()).toContainText("Newest cast"); + await expect(cards).toHaveCount(3); + expect(calls).toContain("/api/v1/latest"); await expect(range).toBeVisible(); await expect(range).toHaveValue("week"); }); diff --git a/e2e/video-sound-focus.fixture.spec.ts b/e2e/video-sound-focus.fixture.spec.ts index 32618e41..c9b7faa1 100644 --- a/e2e/video-sound-focus.fixture.spec.ts +++ b/e2e/video-sound-focus.fixture.spec.ts @@ -33,6 +33,31 @@ test.beforeEach(async ({ page }) => { body: readFileSync(path.join(fixtures, file)), contentType: type, headers: { "access-control-allow-origin": "*" }, }); }); + // Retain a short trusted-input/media timeline if the keyboard transition + // fails, so native activation can be distinguished from a focus/state change. + await page.addInitScript(() => { + const events: Array> = []; + (window as any).soundInput = events; + const record = (event: Event) => { + const target = event.target instanceof Element ? event.target : null; + if (target && !target.closest(".post .video")) return; + queueMicrotask(() => { + const player = document.querySelector(".post .video"); + const video = player?.querySelector("video"); + events.push({ + at: performance.now(), type: event.type, trusted: event.isTrusted, prevented: event.defaultPrevented, + key: (event as KeyboardEvent).key, detail: (event as MouseEvent).detail, + target: target?.className, active: document.activeElement?.className, focused: document.hasFocus(), + label: player?.querySelector(".video-sound")?.textContent, + muted: video?.muted, paused: video?.paused, sound: localStorage.getItem("allthing.sound"), + }); + if (events.length > 64) events.shift(); + }); + }; + for (const kind of ["pointerdown", "pointerup", "touchend", "keydown", "keypress", "keyup", "click", "focus", "blur", "volumechange", "playing", "pause"]) { + window.addEventListener(kind, record, true); + } + }); // The reply's video sits far below, so only one is in view at a time. await page.addInitScript(() => { document.addEventListener("DOMContentLoaded", () => { @@ -61,20 +86,36 @@ for (const width of [1280, 390]) { const before = await page.evaluate(() => window.scrollY); const source = await video.getAttribute("src"); await activateVisible(sound, hasTouch); + let failed = true; try { await expect.poll(() => page.evaluate(() => window.scrollY), { timeout: 2000 }).toBe(before); const enabled = player.getByRole("button", { name: "Sound on · tap to mute", exact: true }); await expect(enabled).toBeFocused(); await expect.poll(() => video.evaluate((v) => !v.muted && !v.paused)).toBe(true); expect(await page.evaluate(() => localStorage.getItem("allthing.sound"))).toBe("on"); - // Keyboard activation still works after pointer focus. - await page.keyboard.press("Space"); + // Hold Space on the pointer-focused control until its keydown has + // been processed, then release it for the native button activation. + // Do not refocus: the same node must keep focus throughout. + const pointerFocused = await enabled.elementHandle(); + await page.keyboard.down("Space"); + try { + await expect(enabled).toBeFocused(); + expect(await enabled.evaluate((button, original) => button === original, pointerFocused)).toBe(true); + await expect(enabled).toHaveAttribute("data-press", "down"); + await expect.poll(() => video.evaluate((v) => !v.muted && !v.paused)).toBe(true); + } finally { + await page.keyboard.up("Space"); + } await expect(sound).toBeFocused(); await expect.poll(() => video.evaluate((v) => v.muted && !v.paused)).toBe(true); expect(await page.evaluate(() => localStorage.getItem("allthing.sound"))).toBeNull(); expect(await page.evaluate(() => window.scrollY)).toBe(before); expect(await video.getAttribute("src")).toBe(source); + failed = false; } finally { + if (failed) { + await info.attach("sound-input", { contentType: "application/json", body: JSON.stringify(await page.evaluate(() => (window as any).soundInput), null, 2) }); + } await page.screenshot({ path: info.outputPath(`video-sound-focus-${width}.png`) }); } }); diff --git a/package-lock.json b/package-lock.json index 1df13c52..85260221 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "allthing", - "version": "0.5.0", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "allthing", - "version": "0.5.0", + "version": "0.5.1", "license": "AGPL-3.0-or-later", "devDependencies": { "@playwright/test": "1.56.1", diff --git a/package.json b/package.json index 3c206c0a..ebc228ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "allthing", - "version": "0.5.0", + "version": "0.5.1", "private": true, "description": "Allthing: a read-only Farcaster client in Rust, served from a Cloudflare Worker.", "license": "AGPL-3.0-or-later", diff --git a/playwright.config.ts b/playwright.config.ts index b40d048d..fa7fd1cf 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -28,6 +28,8 @@ export default defineConfig({ fullyParallel: false, workers: 1, retries: process.env.CI ? 1 : 0, + // A recovered retry is diagnostic evidence, not a clean release check. + failOnFlakyTests: Boolean(process.env.CI), reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : "list", use: { baseURL, diff --git a/scripts/test-browser-fixtures.sh b/scripts/test-browser-fixtures.sh new file mode 100644 index 00000000..5d1adce8 --- /dev/null +++ b/scripts/test-browser-fixtures.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# SPDX-License-Identifier: AGPL-3.0-or-later +# Keep calibrated smoothness measurements alone on the runner. The other +# fixtures run first with two workers, under their existing assertions. +set -u +cd "$(dirname "$0")/.." || exit 1 + +if [ "$#" -ne 0 ]; then + echo "Usage: sh scripts/test-browser-fixtures.sh" >&2 + exit 2 +fi + +functional=0 +echo "Functional fixtures (2 workers)" +PLAYWRIGHT_HTML_OUTPUT_DIR=playwright-report/functional \ + npx playwright test '^(?!.*[/\\]smoothness\.fixture\.spec\.ts$).*\.fixture\.spec\.ts$' \ + --workers=2 --output=test-results/functional || functional=$? + +smoothness=0 +echo "Smoothness fixtures (1 worker, after functional fixtures finish)" +PLAYWRIGHT_HTML_OUTPUT_DIR=playwright-report/smoothness \ + npx playwright test '[/\\]smoothness\.fixture\.spec\.ts$' \ + --workers=1 --output=test-results/smoothness || smoothness=$? + +# Run both phases, including after an assertion failure or runner error. +if [ "$functional" -ne 0 ] || [ "$smoothness" -ne 0 ]; then + exit 1 +fi diff --git a/tests/browser-fixture-phases.test.mjs b/tests/browser-fixture-phases.test.mjs new file mode 100644 index 00000000..37b1d19e --- /dev/null +++ b/tests/browser-fixture-phases.test.mjs @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +const script = fileURLToPath(new URL("../scripts/test-browser-fixtures.sh", import.meta.url)); + +async function run(functional, smoothness, args = []) { + const scratch = await mkdtemp(path.join(tmpdir(), "allthing-browser-phases-")); + try { + const log = path.join(scratch, "calls"); + const npx = path.join(scratch, "npx"); + await writeFile(npx, `#!/bin/sh +printf 'phase\nhtml=%s\nci=%s\n' "$PLAYWRIGHT_HTML_OUTPUT_DIR" "$CI" >> "$PHASE_LOG" +printf 'arg=%s\n' "$@" >> "$PHASE_LOG" +case "$*" in + *--workers=2*) exit "$FUNCTIONAL_EXIT" ;; + *--workers=1*) exit "$SMOOTHNESS_EXIT" ;; + *) exit 90 ;; +esac +`); + await chmod(npx, 0o755); + const result = spawnSync("sh", [script, ...args], { + encoding: "utf8", + env: { ...process.env, PATH: `${scratch}:${process.env.PATH}`, CI: "1", PHASE_LOG: log, FUNCTIONAL_EXIT: String(functional), SMOOTHNESS_EXIT: String(smoothness) }, + }); + const calls = await readFile(log, "utf8").catch((error) => { + if (error.code === "ENOENT") return ""; + throw error; + }); + return { ...result, calls: calls.split("phase\n").filter(Boolean).map((call) => call.trim().split("\n")) }; + } finally { + await rm(scratch, { recursive: true, force: true }); + } +} + +test("runs functional then quiet smoothness with separate reports and unchanged CI policy", async () => { + const result = await run(0, 0); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.calls.length, 2); + assert.deepEqual(result.calls.map((call) => call.slice(0, 2)), [ + ["html=playwright-report/functional", "ci=1"], + ["html=playwright-report/smoothness", "ci=1"], + ]); + assert.deepEqual(result.calls.map((call) => call.filter((arg) => arg.startsWith("arg=--"))), [ + ["arg=--workers=2", "arg=--output=test-results/functional"], + ["arg=--workers=1", "arg=--output=test-results/smoothness"], + ]); +}); + +test("still measures smoothness when functional assertions or the runner fail", async () => { + for (const status of [1, 127]) { + const result = await run(status, 0); + assert.equal(result.status, 1); + assert.equal(result.calls.length, 2); + assert.ok(result.calls[1].includes("arg=--workers=1")); + } +}); + +test("either phase failing makes the aggregate fail", async () => { + for (const statuses of [[0, 1], [1, 1]]) { + const result = await run(...statuses); + assert.equal(result.status, 1); + assert.equal(result.calls.length, 2); + } +}); + +test("rejects extra CLI arguments instead of overriding the phase worker counts", async () => { + const result = await run(0, 0, ["--workers=8"]); + assert.equal(result.status, 2); + assert.equal(result.calls.length, 0); +}); diff --git a/web/paint.js b/web/paint.js index 7f7475d4..9e4c0ed6 100644 --- a/web/paint.js +++ b/web/paint.js @@ -106,9 +106,9 @@ async function paint(payload, { sort, range, reader }) { reading = reader !== null; const notes = payload.notes || {}; const lean = leanings(reader); - // A cast older than its window is left out, as the app leaves it out of a copy from the edge. - const now = Date.now() / 1000; - const inWindow = (cast) => cast.ts > now - SPANS[range]; + // The same inclusive present and exclusive lower edge as TrendingWindow::holds. + const now = Math.floor(Date.now() / 1000); + const inWindow = (cast) => cast.ts <= now && cast.ts > now - SPANS[range]; // Mini app casts only for a reader who shows them (miniapps.rs). const apps = showsMiniApps(); const casts = sorted(payload.pool.filter((c) => !c.parent_hash && inWindow(c) && (apps || !c.mini_app)), sort, lean).slice(0, CARDS);