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).**
-
+
@@ -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! {
+
" · "
diff --git a/crates/allthing-web/src/seen.rs b/crates/allthing-web/src/seen.rs
index 3afe8f00..9973176e 100644
--- a/crates/allthing-web/src/seen.rs
+++ b/crates/allthing-web/src/seen.rs
@@ -184,6 +184,25 @@ impl Feed {
pub fn new(enabled: bool) -> Self {
let seen = use_seen();
let url = use_url();
+ // A query selects a new feed generation. As with Home's ranking,
+ // wait for the selected tab to paint before filtering its cards:
+ // suppressing just-read rows can dispose most of the old list when
+ // progressive drawing restarts, which must not block the tap.
+ let query = RwSignal::new(url.with_untracked(|url| url.search().to_owned()));
+ Effect::new(move |_| {
+ if !enabled {
+ return;
+ }
+ let current = url.get();
+ if crate::kept::is_front(current.path()) && current.search() != query.get_untracked() {
+ crate::components::cast::after_paint(move || {
+ let Some(current) = url.try_get_untracked() else { return };
+ if crate::kept::is_front(current.path()) {
+ let _ = query.try_set(current.search().to_owned());
+ }
+ });
+ }
+ });
let previous = StoredValue::new(Snapshot::default());
let snapshot = Memo::new(move |_| {
if !enabled {
@@ -192,10 +211,7 @@ impl Feed {
let before = previous.get_value();
// Home is kept, detached, while reading a post/profile. Preserve
// that generation for Back, rather than shortening its scroll.
- let query = url.with(|url| {
- if crate::kept::is_front(url.path()) { url.search().to_owned() } else { before.key.2.clone() }
- });
- let key = (seen.scope.get(), seen.epoch.get(), query);
+ let key = (seen.scope.get(), seen.epoch.get(), query.get());
if key == before.key {
return before;
}
diff --git a/crates/allthing-worker/src/index.rs b/crates/allthing-worker/src/index.rs
index 9ebceddb..b393a6f7 100644
--- a/crates/allthing-worker/src/index.rs
+++ b/crates/allthing-worker/src/index.rs
@@ -49,9 +49,10 @@ const STORE: &str = "INSERT INTO casts (hash, ts, points) \
/// Rank span ?1 (casts from ?2 on, Unix seconds) as of ?3 (Unix ms): its top
/// ?4 casts, how many it has, its oldest, and its floor once full.
const RERANK: &str = "WITH top AS (\
- SELECT hash, ts, points FROM casts WHERE ts >= ?2 ORDER BY points DESC, ts DESC LIMIT ?4) \
+ SELECT hash, ts, points FROM casts WHERE ts >= ?2 AND ts <= CAST(?3 / 1000 AS INTEGER) ORDER BY points DESC, ts DESC LIMIT ?4) \
REPLACE INTO rankings (name, built, total, oldest, floor, body) \
- SELECT ?1, ?3, (SELECT COUNT(*) FROM casts WHERE ts >= ?2), (SELECT MIN(ts) FROM casts WHERE ts >= ?2), \
+ SELECT ?1, ?3, (SELECT COUNT(*) FROM casts WHERE ts >= ?2 AND ts <= CAST(?3 / 1000 AS INTEGER)), \
+ (SELECT MIN(ts) FROM casts WHERE ts >= ?2 AND ts <= CAST(?3 / 1000 AS INTEGER)), \
CASE WHEN COUNT(*) >= ?4 THEN MIN(points) ELSE 0 END, json_group_array(json_array(hash, ts, points)) \
FROM top";
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index a92984fa..ec7d4fc6 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -157,7 +157,7 @@ Hypersnap ranks trending casts over 7 days at most, and its search ignores dates
- **Storage.** `casts` holds each kept cast's hash, time and points; storing it again updates its points, and writes nothing if they haven't changed. `rankings` holds each span's top 1,000 casts as one JSON row, recomputed inside D1 by one statement, so the Worker never reads more than a page of casts. A ranking is due again a minute per 50 indexed casts after it was computed, between 5 minutes and 6 hours. `state` holds where each feed is, and the day's D1 usage.
- **Limits.** The indexer estimates its D1 rows read and written and stops reading for the day at 50,000 rows written or 2,000,000 read, half of the free plan's 100,000 and 5,000,000 or less, leaving the rest to the front page. See [BUDGET.md](BUDGET.md#9-the-index-cron-and-d1).
- **Reading.** `/api/v1/home?window=30d|365d|all` reads the span's ranking row, then the page's 100 casts in one bulk `casts?casts=…` call, so counts, text and media are fresh; deleted casts drop out, spam is filtered again and mini apps are marked. The payload's `index` (`IndexCoverage`) says how many casts the span was ranked from and the oldest one's date, and the page shows it under the time filter. Until a span has a ranking (a new index, or D1 not answering), the payload is the past week's trending pool without `index`, and the page says so. The static copy has no index: its menu leaves these ranges out.
-- **Time windows.** A trending window holds only the casts posted within it, judged by each cast's own time against the Worker's clock, before the page is cut (`TrendingWindow::holds`, the same edges in the browser, which drops what has aged out of a copy it was served). Hypersnap's `time_window` has 1 hour, 6 hours, 24 hours and 7 days; it answers 12 hours with its 24-hour list, which is cut to 12 hours. A page can hold fewer casts than a full batch, when the window holds fewer: the list then ends, and the page says so ("Only 3 casts in the past 12 hours.") instead of padding it with older casts. A copy is kept no longer than its window (`api::home_cache`).
+- **Time windows.** A trending window holds only the casts posted within it, judged by each cast's own time against the Worker's clock, before the page is cut (`TrendingWindow::holds`, the same edges in the browser and early paint). The lower edge is exclusive and the present is inclusive: `now - span < ts <= now`. All time has no lower edge, but still excludes future timestamps. Following and newest-cast responses, including those read from a stored snapshot, also exclude the future, and index rankings cannot let future rows fill their slots. Ingest alone allows five seconds of source clock skew (`may_ingest_timestamp`, an Allthing buffer allowance, not a protocol threshold): the existing 100-cast newest buffer and index may retain those casts, while later dates are discarded before the buffer is cut. A retained cast can become visible when the clock catches up, but overflow or advancing a filtered stateless cursor does not guarantee another chance to serve it; there is no deferred queue. These checks use existing reads; the stored newest-casts fast path parses its payload to validate timestamps and serializes again only when rows are removed, without changing the stored source or its age. Hypersnap's `time_window` has 1 hour, 6 hours, 24 hours and 7 days; it answers 12 hours with its 24-hour list, which is cut to 12 hours. A page can hold fewer casts than a full batch, when the window holds fewer: the list then ends, and the page says so ("Only 3 casts in the past 12 hours.") instead of padding it with older casts. A copy is kept no longer than its window (`api::home_cache`).
- **Coverage** grows as the feeds are read back: Past month and Past year fill within hours, and All time keeps deepening for days as the queued authors' histories are read. The ranking reflects what has been indexed, which the note under the filter states.
## Daily active accounts
diff --git a/docs/BUDGET.md b/docs/BUDGET.md
index 82bb7494..15837e6a 100644
--- a/docs/BUDGET.md
+++ b/docs/BUDGET.md
@@ -71,6 +71,8 @@ Images and videos never touch the Worker, except readers' own attachments, which
The front page fills the space near the end of the list even when filtering leaves a batch short or empty. It can request offsets 100 through 900, at most nine additional payloads (1,000 source casts including the first page). Each payload remains a separate cached, guarded invocation with the same 45-subrequest budget. Failed pages keep their 2, 4 and 8 second retry delays. Deep scrolling or short filtered batches can therefore cost up to ten home requests per visit; the typical capacity estimate below does not describe that worst case.
+Future timestamp checks use the existing payloads and add no cache, D1 or upstream reads. Following keeps its existing decision about reading a second source page before invalid dates are removed. Ingest may retain five seconds of source clock skew in the existing newest-cast buffer and index, without a deferred queue; public responses and SQL rankings still exclude all future timestamps. Serving the stored newest-casts snapshot parses its timestamps; unchanged snapshots retain their exact bytes and only filtered snapshots are serialized again. Filtering does not mutate the stored source or reset its age. The invocation still has the same 45-subrequest limit.
+
**Prefetching** ([PERFORMANCE.md](PERFORMANCE.md)) spends Worker requests ahead of clicks, within caps:
- hover or touch on a card: one `/api/v1/cast` request, deduplicated;
- a link to a post or a channel pressed, touched, focused, or with a mouse resting on it for 100 ms: one request for the page it opens (`/api/v1/cast` or `/api/v1/channel`), deduplicated and reused for 5 minutes; a press asks for the page's own request a moment early, and only a link rested on or focused but not followed costs one more;
diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md
index 1c35f8e3..cc732d30 100644
--- a/docs/PERFORMANCE.md
+++ b/docs/PERFORMANCE.md
@@ -15,8 +15,12 @@ Navigation should feel instant: a post opens as soon as it is clicked, the feed
The CI `perf` job runs `e2e/perf.spec.ts` against https://allthing.social after every push to `main` (and on demand through `workflow_dispatch`). Before measuring, it waits for `/version` to report the checked-out commit (the triggering commit for a push, or current `main` for a manual run), or a later commit on `main` that includes it. Workers Builds skips a commit that a newer push supersedes: of five merges made within 33 seconds on 19 October 2026 it built the first and the last, so a commit can be live only as part of a later one, which the job tells from the repository's history. It polls every 10 seconds for at most 30 minutes, aborts an individual version read after five seconds, and fails without benchmarking if that release never arrives. Workers Builds takes 6 to 16 minutes from a push until the commit is live, and about 25 when a second merge queues behind the first: the five-minute wait it replaced gave up 21 seconds before #144 went live on 19 October 2026, and from its introduction (#133) the job failed on every push to `main` it ran for, without measuring anything. This keeps a pending deployment from passing the budget with the previous release's results.
+Each cold-home sample logs its first-card and LCP times, the LCP element and image dimensions/loading hints, navigation and resource phases, cache/Server-Timing metadata, and main-thread long tasks. The JSON attachment `home-lcp-diagnostics.json` is retained in the `playwright-performance` Actions artifact for seven days, even when readiness or the budget fails. URLs retain only origin and pathname; credentials, query strings, fragments and Server-Timing descriptions are omitted. Cross-origin timing phases can be zero when the image host sends no Timing-Allow-Origin header. These observations add no warm-up fetch, wait, cache override or resource priority change: the same three cold contexts and 1.5-second median budget apply.
+
## Smoothness
+CI runs functional fixture files with two workers, then runs `smoothness.fixture.spec.ts` alone with one worker (`scripts/test-browser-fixtures.sh`). Both phases run even if the first fails, and either failure fails the existing browser job. Their HTML reports and test results are kept separately, including from the later static checks; the production performance job, test retries, timing assertions and 45-minute job cap are unchanged.
+
How the app answers a reader's taps, clicks and scrolls: `e2e/smoothness.fixture.spec.ts`, which CI's browser job runs on every pull request and push to `main`, on the local build with the Worker's API, the node and the images as stand-ins (the API answers after 60 to 180 ms). A signed-in reader with an approved key, on a phone (390 × 844, touch, the CPU slowed 2×) and on a computer (1280 × 900, a mouse):
- taps the sort tabs: new, top, hot;
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
index df5b083b..88a1ae3c 100644
--- a/docs/RELEASING.md
+++ b/docs/RELEASING.md
@@ -54,7 +54,7 @@ Scopes are optional, for example `feat(profile): …`, `fix(worker): …` or `pe
```sh
gh release create vX.Y.Z --title "Allthing X.Y.Z" --notes-file <(awk '/^## \[X.Y.Z\]/{f=1;next} /^## \[/{f=0} f' CHANGELOG.md)
```
-6. Workers Builds deploys the merge. Check that `https://allthing.social/version` reports `X.Y.Z` and the merge commit. Then run `E2E_BASE_URL=https://allthing.social npx playwright test`.
+6. Workers Builds deploys the merge. Check that `https://allthing.social/version` reports `X.Y.Z` and the merge commit. Then run `E2E_BASE_URL=https://allthing.social E2E_EXPECT_WRITES_READY=true npx playwright test`. The readiness contract defaults to `false` for local development without a custody secret; deployments that endorse keys set it explicitly to `true` (`true` and `false` are the only accepted values).
## Hotfixes
diff --git a/docs/screenshots/dau.webp b/docs/screenshots/dau.webp
index 59993489..48840fd5 100644
Binary files a/docs/screenshots/dau.webp and b/docs/screenshots/dau.webp differ
diff --git a/docs/screenshots/home.webp b/docs/screenshots/home.webp
index b3d9ca9b..bfb558ef 100644
Binary files a/docs/screenshots/home.webp and b/docs/screenshots/home.webp differ
diff --git a/docs/screenshots/profile.webp b/docs/screenshots/profile.webp
index 88170336..8b605c69 100644
Binary files a/docs/screenshots/profile.webp and b/docs/screenshots/profile.webp differ
diff --git a/e2e/allthing.spec.ts b/e2e/allthing.spec.ts
index 81227b3f..3bdbf510 100644
--- a/e2e/allthing.spec.ts
+++ b/e2e/allthing.spec.ts
@@ -162,23 +162,50 @@ test.describe("front page", () => {
await shoot(page, "home");
});
- test("new and top re-rank the same pool without another API call", async ({ page }) => {
+ test("New reads latest casts and Top reuses trending without refetching Home", async ({ page }) => {
+ // This live read checks feed navigation without recording a presence beat.
+ await page.route("**/api/v1/presence", (route) => route.fulfill({ status: 404, json: { error: "Read-only test" } }));
await page.goto(at("/"));
await expect(firstCard(page)).toBeVisible();
+ const latestRead = PAGES ? null : page.waitForResponse((response) =>
+ new URL(response.url()).pathname === "/api/v1/latest" && response.request().method() === "GET",
+ );
const calls = await apiRequests(page, async () => {
await page.locator(".sort-tabs a", { hasText: "new" }).click();
await expect(page).toHaveURL(/sort=new/);
+ if (latestRead) {
+ const response = await latestRead;
+ expect(response.status()).toBe(200);
+ const hashes = (await response.json()).pool.map((cast: { hash: string }) => cast.hash);
+ // URL changes and carried cards can precede the new resource. Check
+ // the actual Latest payload before judging the visible chronology.
+ await expect.poll(() => page.locator(".cast-card").evaluateAll(
+ (cards, allowed) => cards.length > 0 && cards.every((card) => allowed.includes(card.getAttribute("data-hash")!)),
+ hashes,
+ )).toBe(true);
+ }
await expect(firstCard(page)).toBeVisible();
});
- // Prefetches of posts may run meanwhile; the feed itself is not refetched.
+ // New uses its own source; the previous trending payload is reused.
expect(calls.filter((c) => /\/api\/v1\/home|feed\/trending/.test(c)), "sorting is local").toHaveLength(0);
- const times = await page.locator(".cast-card").evaluateAll((c) => c.map((e) => Number(e.getAttribute("data-ts"))));
- expect(times).toEqual([...times].sort((a, b) => b - a));
-
- await page.locator(".sort-tabs a", { hasText: "top" }).click();
- await expect(page).toHaveURL(/sort=top/);
- const points = await page.locator(".cast-card").evaluateAll((c) => c.map((e) => Number(e.getAttribute("data-points"))));
- expect(points).toEqual([...points].sort((a, b) => b - a));
+ if (!PAGES) expect(calls.filter((call) => /\/api\/v1\/latest/.test(call))).toHaveLength(1);
+ await expect(async () => {
+ const times = await page.locator(".cast-card").evaluateAll((c) => c.map((e) => Number(e.getAttribute("data-ts"))));
+ expect(times.length).toBeGreaterThan(0);
+ expect(times).toEqual([...times].sort((a, b) => b - a));
+ }).toPass({ timeout: 5000 });
+
+ const topCalls = await apiRequests(page, async () => {
+ await page.locator(".sort-tabs a", { hasText: "top" }).click();
+ await expect(page).toHaveURL(/sort=top/);
+ // The selected sort paints before its rows are reordered.
+ await expect(async () => {
+ const points = await page.locator(".cast-card").evaluateAll((c) => c.map((e) => Number(e.getAttribute("data-points"))));
+ expect(points.length).toBeGreaterThan(0);
+ expect(points).toEqual([...points].sort((a, b) => b - a));
+ }).toPass({ timeout: 5000 });
+ });
+ expect(topCalls.filter((call) => /\/api\/v1\/home|feed\/trending/.test(call)), "Top reuses trending").toHaveLength(0);
});
test("a feed page that fails to load is tried again", async ({ page }) => {
@@ -675,13 +702,16 @@ test.describe("threads and navigation", () => {
await expect(page).toHaveURL(new RegExp(`/p/${hash}$`));
});
- test("back lays out the feed's images at their size before they load", async ({ page }) => {
+ test("Back preserves loaded feed images and their exact size", async ({ page }) => {
+ // Keep this image/navigation check read-only on the live deployment.
+ await page.route("**/api/v1/presence", (route) => route.fulfill({ status: 404, json: { error: "Read-only test" } }));
await page.goto(at("/"));
await expect(firstCard(page)).toBeVisible();
// A card with a single image, scrolled past so its image has loaded.
const image = page.locator(".cast-card > .media-grid.n1 .tile-image img").first();
await image.scrollIntoViewIfNeeded();
await expect.poll(() => image.evaluate((img: HTMLImageElement) => img.complete && img.naturalHeight > 0)).toBe(true);
+ const original = await image.elementHandle();
const hash = await image.evaluate((img) => img.closest(".cast-card")!.getAttribute("data-hash")!);
const height = await image.evaluate((img) => Math.round(img.getBoundingClientRect().height));
expect(height).toBeGreaterThan(50);
@@ -698,10 +728,13 @@ test.describe("threads and navigation", () => {
},
);
await page.goBack();
+ await expect(page).toHaveURL(new RegExp(`${at("/")}$`));
const again = page.locator(`.cast-card[data-hash="${hash}"] > .media-grid.n1 .tile-image img`);
await expect(again).toBeAttached();
- // Its box is already the loaded image's size: nothing below it will move.
- expect(await again.evaluate((img: HTMLImageElement) => img.complete)).toBe(false);
+ // Back remounts the retained feed: its loaded image and exact box stay
+ // intact even while newly requested images would still be delayed.
+ expect(await again.evaluate((img, kept) => img === kept, original)).toBe(true);
+ expect(await again.evaluate((img: HTMLImageElement) => img.complete && img.naturalHeight > 0)).toBe(true);
expect(Math.abs((await again.evaluate((img) => img.getBoundingClientRect().height)) - height)).toBeLessThanOrEqual(2);
});
@@ -1164,9 +1197,14 @@ test.describe("hosting", () => {
test("static assets are long-cached and the API is edge-cached", async ({ request }) => {
const index = await request.get("/");
- const asset = /\/assets\/allthing-[0-9a-f]+\.wasm/.exec(await index.text())?.[0];
- expect(asset).toBeTruthy();
- const wasm = await request.get(asset!);
+ // The boot module comes from /wasm on Workers, or /assets on a static
+ // host. Discover its exact fingerprinted path, excluding idle splits;
+ // multiple main-module references must still fail this contract.
+ const modules = (await index.text()).match(/\/(?:assets|wasm)\/allthing-[0-9a-f]+\.wasm(?=["'\s<>])/g) ?? [];
+ expect(modules).toHaveLength(1);
+ const wasm = await request.get(modules[0]);
+ expect(wasm.status()).toBe(200);
+ expect(wasm.headers()["content-type"]).toBe("application/wasm");
expect(wasm.headers()["cache-control"]).toContain("immutable");
await request.get("/api/v1/home");
const second = await request.get("/api/v1/home");
diff --git a/e2e/boot-recovery.fixture.spec.ts b/e2e/boot-recovery.fixture.spec.ts
index 5006788f..6944db13 100644
--- a/e2e/boot-recovery.fixture.spec.ts
+++ b/e2e/boot-recovery.fixture.spec.ts
@@ -67,13 +67,19 @@ for (const width of [1280, 390]) {
const modules: { path: string, status: number, type: string | undefined }[] = [];
page.on("response", (response) => {
const path = new URL(response.url()).pathname;
- if (path.endsWith(".wasm")) modules.push({ path, status: response.status(), type: response.headers()["content-type"] });
+ // As in the fallback case, count exactly one main module, including
+ // any unexpected /assets fallback. Idle split/shared modules are separate.
+ if (/\/(?:assets|wasm)\/allthing-[0-9a-f]+\.wasm$/.test(path)) modules.push({ path, status: response.status(), type: response.headers()["content-type"] });
});
await page.route("**/api/v1/**", (route) => route.fulfill({ json: {
pool: [], meta: { backend: "hypersnap", fetched_at: Date.now() },
} }));
+ // Exercise the ordering where valid idle modules finish before the main
+ // module assertions, rather than relying on the header appearing first.
+ const idlePoll = page.waitForResponse((response) => /\/split_poll\.wasm$/.test(new URL(response.url()).pathname));
await page.goto("/");
await expect(page.getByRole("combobox", { name: "Search casts, people and channels", exact: true })).toBeVisible();
+ expect((await idlePoll).status()).toBe(200);
expect(modules).toHaveLength(1);
expect(modules[0].path).toMatch(/^\/wasm\/allthing-[0-9a-f]+\.wasm$/);
expect(modules[0].status).toBe(200);
diff --git a/e2e/dau.fixture.spec.ts b/e2e/dau.fixture.spec.ts
index 6a8a5db4..4f8e2a23 100644
--- a/e2e/dau.fixture.spec.ts
+++ b/e2e/dau.fixture.spec.ts
@@ -1,6 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { expect, test, type Browser, type Locator, type Page, type TestInfo } from "@playwright/test";
-import { NOW } from "./fixture-time";
/*
* /dau: Farcaster's daily active accounts as the cron counts them, laid out
@@ -388,7 +387,9 @@ test.describe("with the Worker", () => {
test("a client card links to the page, which the app opens without a reload", async ({ page }) => {
await serve(page, payload());
const hash = (i: number) => `0x${(i + 1).toString(16).padStart(40, "0")}`;
- const pool = Array.from({ length: 25 }, (_, i) => ({ hash: hash(i), author: { fid: 1, username: "fixture" }, text: `DAU link fixture ${i}`, ts: (NOW - 19) - i }));
+ // `serve` pins this browser to noon. Process time can be later, which
+ // would make every row future-dated and correctly remove the app legend.
+ const pool = Array.from({ length: 25 }, (_, i) => ({ hash: hash(i), author: { fid: 1, username: "fixture" }, text: `DAU link fixture ${i}`, ts: now - 19 - i }));
const origins = Object.fromEntries(pool.map((c) => [c.hash, { signer: "0x01", app_fid: 9152 }]));
await page.route("**/api/v1/home**", (route) => route.fulfill({
json: { pool, notes: { origins, apps: { "9152": { fid: 9152, username: "warpcast", display_name: "Warpcast" } } }, meta: payload().meta, apps_24h: payload().apps_24h },
diff --git a/e2e/early-paint.fixture.spec.ts b/e2e/early-paint.fixture.spec.ts
index 96a782e3..2b373e0b 100644
--- a/e2e/early-paint.fixture.spec.ts
+++ b/e2e/early-paint.fixture.spec.ts
@@ -205,6 +205,22 @@ async function moduleIn(page: Page, held: Awaited>) {
.toBe(true);
}
+test("future dates never appear in early paint or after the app takes over", async ({ page }) => {
+ const held = await hold(page);
+ const future = { ...favorite, hash: hex(900), ts: 2_893_718_638, likes: 1_000_000, embeds: [] };
+ await serve(page, { ...payload, pool: [future, ...pool] });
+ await favorites(page);
+ await page.goto("/");
+ const boot = page.locator("#boot");
+ await expect.poll(() => boot.locator(".cast-card").evaluateAll((cards) => cards.map((card) => card.getAttribute("data-hash")))).toEqual(painted);
+ await expect(boot.locator(`[data-hash="${future.hash}"]`)).toHaveCount(0);
+ await moduleIn(page, held);
+ held.js("continue");
+ await expect(boot).toHaveCount(0);
+ await expect(page.locator("#app .cast-card").first()).toBeVisible();
+ await expect(page.locator(`#app [data-hash="${future.hash}"]`)).toHaveCount(0);
+});
+
for (const width of [1280, 390]) {
test(`the front page's first cards come before the app, as it draws them, and its own take their places at width ${width}`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 844 });
diff --git a/e2e/emoji-picker.fixture.spec.ts b/e2e/emoji-picker.fixture.spec.ts
index 7f5e206d..a6bc77ca 100644
--- a/e2e/emoji-picker.fixture.spec.ts
+++ b/e2e/emoji-picker.fixture.spec.ts
@@ -184,6 +184,24 @@ function headingTop(picker: Locator, name: string) {
test("the groups' bar brings a group to the top, and marks the one in view as the grid scrolls", async ({ page }) => {
await serve(page);
const { dialog } = await compose(page);
+ // Keep Food as the last drawn group: its heading is already mounted, but
+ // later groups have not supplied the scroll room needed to put it at the top.
+ // This slow progressive-paint state must work without a second click.
+ await page.evaluate(() => {
+ const frame = window.requestAnimationFrame.bind(window);
+ const held: Array<() => void> = [];
+ window.requestAnimationFrame = (callback) => frame((time) => {
+ if (document.querySelectorAll(".emoji-group-heading").length === 8) {
+ held.push(() => callback(time));
+ } else {
+ callback(time);
+ }
+ });
+ (window as any).releaseEmojiFrames = () => {
+ window.requestAnimationFrame = frame;
+ held.splice(0).forEach((callback) => callback());
+ };
+ });
await dialog.getByRole("button", { name: "Add an emoji" }).click();
const picker = dialog.getByRole("region", { name: "Pepe emoji" });
const bar = picker.getByRole("group", { name: "Groups" });
@@ -191,8 +209,20 @@ test("the groups' bar brings a group to the top, and marks the one in view as th
const marked = (name: string) => expect(bar.getByRole("button", { name, exact: true })).toHaveAttribute("aria-current", "true");
// Each button's face is its group's first emoji, still.
await expect(bar.getByRole("button", { name: "Food and drink" }).locator("img")).toHaveAttribute("src", /pepe_tea\.still\.webp$/);
+ await expect(picker.locator(".emoji-group-heading")).toHaveCount(8);
+ const geometry = await grid.evaluate((grid) => {
+ const heading = [...grid.querySelectorAll("h4")].find((h) => h.textContent === "Food and drink")!;
+ return {
+ groups: grid.querySelectorAll("h4").length, offsetTop: heading.offsetTop,
+ scrollTop: grid.scrollTop, maxScrollTop: grid.scrollHeight - grid.clientHeight,
+ headingTop: heading.getBoundingClientRect().top - grid.getBoundingClientRect().top,
+ };
+ });
+ await test.info().attach("partial-group-geometry", { contentType: "application/json", body: JSON.stringify(geometry) });
+ expect(geometry.offsetTop).toBeGreaterThan(geometry.maxScrollTop);
await bar.getByRole("button", { name: "Food and drink" }).click();
await expect.poll(() => headingTop(picker, "Food and drink")).toBe(0);
+ await page.evaluate(() => (window as any).releaseEmojiFrames());
await marked("Food and drink");
await expect(bar.locator("[aria-current]")).toHaveCount(1);
// Scrolled by hand, the mark follows the heading that has reached the top.
diff --git a/e2e/favorites.fixture.spec.ts b/e2e/favorites.fixture.spec.ts
index becb203e..74b8da8d 100644
--- a/e2e/favorites.fixture.spec.ts
+++ b/e2e/favorites.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";
/*
@@ -14,7 +15,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");
@@ -49,6 +51,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/feed-session-ranges.fixture.spec.ts b/e2e/feed-session-ranges.fixture.spec.ts
index c4ff0d4c..de91c4c6 100644
--- a/e2e/feed-session-ranges.fixture.spec.ts
+++ b/e2e/feed-session-ranges.fixture.spec.ts
@@ -16,6 +16,9 @@ const cast = (n: number, age: number, likes = 0) => ({
});
const pool = [1800, 3 * 3600, 9 * 3600, 18 * 3600, 2 * 86400, 15 * 86400, 100 * 86400, 400 * 86400]
.map((age, i) => cast(i + 1, age, i * 10));
+// Upstreams and old cached payloads can contain invalid future dates. They
+// must never dominate Hot/Top/New, even in All time or Following.
+pool.push(cast(9, -86400, 1_000_000), { ...cast(10, 0, 1_000_000), ts: 2_893_718_638 });
const order = (page: Page) => page.locator("#app .cast-list .cast-card:not(.skeleton)")
.evaluateAll((cards) => cards.map((card) => card.querySelector(".cast-text")?.textContent?.trim()));
@@ -54,7 +57,7 @@ for (const width of [1280, 390]) {
const selector = page.getByRole("combobox", { name: "Time range" });
await expect(selector).toBeVisible();
await expect(selector).toHaveValue(range);
- const expected = pool.filter((c) => now - c.ts < span).map((c) => c.text);
+ 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());
if (sort === "new") expect(await order(page)).toEqual(expected);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
@@ -75,21 +78,40 @@ async function switchReader(page: Page, user: typeof me | null) {
}, user);
}
-test("Following hot ranks within its window before applying favorites", async ({ page }) => {
+for (const feed of ["For you", "Following"]) {
+ test(`${feed} hot ranks within its window before applying favorites`, async ({ page }) => {
+ await signIn(page, me);
+ await serve(page);
+ await page.addInitScript(() => localStorage.setItem("allthing.favorites", JSON.stringify([{ fid: 1, username: "author1" }])));
+ const quietFavorite = cast(1, 50 * 60);
+ const busyRecent = cast(2, 10 * 60, 100);
+ const outside = cast(3, 400 * 86400);
+ const future = { ...cast(4, 0), ts: 2_893_718_638 };
+ for (const path of ["home", "following"]) await page.route(`**/api/v1/${path}**`, (route) => route.fulfill({
+ json: { pool: [future, outside, quietFavorite, busyRecent], notes: {}, meta }, headers: privateHeaders,
+ }));
+ await page.goto(`/?t=hour${feed === "Following" ? "&feed=following" : ""}`);
+ // In an hour's pool, 25x a quiet cast's weight cannot beat 100 points
+ // posted more recently. Including an old or future cast before ranking
+ // scales that lift by its entire span, sending the favorite to the top.
+ await expect.poll(() => order(page)).toEqual([busyRecent.text, quietFavorite.text]);
+ });
+}
+
+test("Following crosses a cached future-only page to reach the present", async ({ page }) => {
await signIn(page, me);
await serve(page);
- await page.addInitScript(() => localStorage.setItem("allthing.favorites", JSON.stringify([{ fid: 1, username: "author1" }])));
- const quietFavorite = cast(1, 50 * 60);
- const busyRecent = cast(2, 10 * 60, 100);
- const outside = cast(3, 400 * 86400);
- await page.route("**/api/v1/following**", (route) => route.fulfill({
- json: { pool: [outside, quietFavorite, busyRecent], notes: {}, meta }, headers: privateHeaders,
- }));
+ const asked: number[] = [];
+ const future = { ...cast(40, 0), ts: 2_893_718_638 };
+ const present = cast(41, 60);
+ await page.route("**/api/v1/following**", (route) => {
+ const cursor = Number(new URL(route.request().url()).searchParams.get("cursor") || 0);
+ asked.push(cursor);
+ return route.fulfill({ json: { pool: cursor === 0 ? [future] : [present], next: cursor === 0 ? "1" : null, notes: {}, meta }, headers: privateHeaders });
+ });
await page.goto("/?feed=following&t=hour");
- // In an hour's pool, 25x a quiet cast's weight cannot beat 100 points
- // posted more recently. Including the old cast before ranking scales
- // that lift by 400 days, incorrectly sending the favorite to the top.
- await expect.poll(() => order(page)).toEqual([busyRecent.text, quietFavorite.text]);
+ await expect.poll(() => order(page)).toEqual([present.text]);
+ expect(asked).toEqual([0, 1]);
});
test("Following crosses empty pages, stops at an old tail, and resumes when the range widens", async ({ page }) => {
diff --git a/e2e/feed-state.fixture.spec.ts b/e2e/feed-state.fixture.spec.ts
index 68f513cf..cd904f9e 100644
--- a/e2e/feed-state.fixture.spec.ts
+++ b/e2e/feed-state.fixture.spec.ts
@@ -2,14 +2,14 @@
import { expect, test } from "@playwright/test";
test.skip(Boolean(process.env.E2E_PAGES), "uses the Worker API contract");
-const cast = (id: number, likes: number, time: number) => ({
+const cast = (id: number, likes: number, ageHours: number) => ({
hash: `0x${id.toString(16).padStart(40, "0")}`, author: { fid: 1, username: "fixture" },
- text: `Feed fixture ${id}`, ts: Math.floor(Date.now() / 1000) - 3600 + time, likes,
+ text: `Feed fixture ${id}`, ts: Math.floor(Date.now() / 1000) - ageHours * 3600, likes,
});
// Hours apart, so hot (which counts age) and top order each page differently. Their times
-// are relative to now: the page keeps only casts inside its window, and the day is the default.
-const first = [cast(1, 20, 100), cast(2, 5, 100_000)];
-const next = [cast(3, 10, 200_000), cast(4, 30, 200)];
+// are all in the past and inside the default day window.
+const first = [cast(1, 20, 20), cast(2, 5, 1)];
+const next = [cast(3, 10, 2), cast(4, 30, 19)];
const meta = { backend: "hypersnap", fetched_at: Date.now() };
test.beforeEach(async ({ page }) => {
diff --git a/e2e/helpers/perf-diagnostics.ts b/e2e/helpers/perf-diagnostics.ts
new file mode 100644
index 00000000..6cf660a1
--- /dev/null
+++ b/e2e/helpers/perf-diagnostics.ts
@@ -0,0 +1,93 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+import type { Page } from "@playwright/test";
+
+/** URLs in reports never retain credentials, queries, fragments or data bodies. */
+export function safePerfUrl(raw: string): string {
+ try {
+ const url = new URL(raw);
+ return /^https?:$/.test(url.protocol) ? url.origin + url.pathname : `${url.protocol}[omitted]`;
+ } catch { return "[invalid URL]"; }
+}
+
+/** Observe without warming, blocking, fetching or changing browser priorities. */
+export function perfDiagnosticsProbe() {
+ const state = ((window as any).__allthingPerfDiagnostics = { lcp: null as any, appCast: 0, longtasks: [] as any[] });
+ new MutationObserver((_, observer) => {
+ if (document.querySelector("#app .cast-card:not(.skeleton)") && !document.querySelector("#boot[data-painted]")) {
+ state.appCast = performance.now();
+ observer.disconnect();
+ }
+ }).observe(document, { childList: true, subtree: true });
+ new PerformanceObserver((list) => {
+ for (const entry of list.getEntries() as any[]) {
+ const element = entry.element as HTMLImageElement | null;
+ state.lcp = {
+ time: entry.startTime, render: entry.renderTime, load: entry.loadTime, size: entry.size, url: entry.url,
+ element: element ? { tag: element.tagName, classes: element.getAttribute("class"), parentClasses: element.parentElement?.getAttribute("class"), loading: element.getAttribute("loading"), priority: element.getAttribute("fetchpriority"), width: element.getAttribute("width"), height: element.getAttribute("height"), naturalWidth: element.naturalWidth, naturalHeight: element.naturalHeight } : null,
+ };
+ }
+ }).observe({ type: "largest-contentful-paint", buffered: true });
+ new PerformanceObserver((list) => {
+ for (const entry of list.getEntries()) {
+ if (state.longtasks.length < 100) state.longtasks.push({ start: entry.startTime, duration: entry.duration });
+ }
+ }).observe({ type: "longtask", buffered: true });
+}
+
+/** Descriptions can contain URLs; retain only metric names and numeric durations. */
+export function perfServerTiming(raw?: string) {
+ // A quoted description may contain commas or strings resembling dur=.
+ const parts = (raw ?? "").match(/(?:[^,"]|"(?:\\.|[^"\\])*")+/g) ?? [];
+ return parts.flatMap((part) => {
+ const name = part.trim().match(/^([A-Za-z0-9_-]+)/)?.[1];
+ if (!name) return [];
+ const parameters = part.replace(/"(?:\\.|[^"\\])*"/g, '""');
+ const duration = Number(parameters.match(/;\s*dur=([0-9.]+)/)?.[1] ?? 0);
+ return [{ name, duration: Number.isFinite(duration) ? duration : 0 }];
+ });
+}
+
+type ResponseTiming = { url: string; status: number; cache?: string; cfCache?: string; age?: string; serverTiming: ReturnType; colo?: string };
+
+/** A bounded metadata log, read from the response event's existing headers. */
+export function perfResponses(page: Page): ResponseTiming[] {
+ const log: ResponseTiming[] = [];
+ page.on("response", (response) => {
+ if (log.length >= 256) return;
+ const headers = response.headers();
+ const colo = headers["cf-ray"]?.split("-").pop();
+ log.push({ url: safePerfUrl(response.url()), status: response.status(), cache: headers["x-allthing-cache"], cfCache: headers["cf-cache-status"], age: headers.age, serverTiming: perfServerTiming(headers["server-timing"]), colo: colo && /^[A-Z]{3}$/.test(colo) ? colo : undefined });
+ });
+ return log;
+}
+
+/** Resource phases use navigation-relative milliseconds; zero can mean missing TAO. */
+export async function readPerfDiagnostics(page: Page, responses: ResponseTiming[]) {
+ const sample = await page.evaluate(() => {
+ const state = (window as any).__allthingPerfDiagnostics;
+ const navigation = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming;
+ const resources = performance.getEntriesByType("resource") as PerformanceResourceTiming[];
+ const resource = (entry: PerformanceResourceTiming) => ({
+ url: entry.name, kind: entry.initiatorType, start: entry.startTime, request: entry.requestStart,
+ response: entry.responseStart, end: entry.responseEnd, duration: entry.duration,
+ transferBytes: entry.transferSize, encodedBytes: entry.encodedBodySize, decodedBytes: entry.decodedBodySize,
+ serverTiming: entry.serverTiming.map(({ name, duration }) => ({ name, duration })),
+ });
+ return {
+ navigation: { start: navigation.startTime, dnsStart: navigation.domainLookupStart, dnsEnd: navigation.domainLookupEnd, connectStart: navigation.connectStart, tlsStart: navigation.secureConnectionStart, connectEnd: navigation.connectEnd, request: navigation.requestStart, response: navigation.responseStart, end: navigation.responseEnd, dom: navigation.domContentLoadedEventEnd, load: navigation.loadEventEnd },
+ lcp: state.lcp,
+ appCast: state.appCast,
+ lcpResource: resources.find((entry) => entry.name === state.lcp?.url) ? resource(resources.find((entry) => entry.name === state.lcp.url)!) : null,
+ resources: resources.filter((entry) => new URL(entry.name).origin === location.origin && /\/api\/v1\/home|\.wasm|\.(?:js|css|woff2)(?:\?|$)/.test(entry.name)).map(resource),
+ longtasks: state.longtasks,
+ };
+ });
+ const cleanResource = (entry: NonNullable) => ({ ...entry, url: safePerfUrl(entry.url) });
+ return {
+ ...sample,
+ lcp: sample.lcp ? { ...sample.lcp, url: sample.lcp.url ? safePerfUrl(sample.lcp.url) : "" } : null,
+ lcpResource: sample.lcpResource ? cleanResource(sample.lcpResource) : null,
+ resources: sample.resources.map(cleanResource),
+ responses,
+ };
+}
diff --git a/e2e/hover-card-leanings.fixture.spec.ts b/e2e/hover-card-leanings.fixture.spec.ts
index d8c661c1..44a2c892 100644
--- a/e2e/hover-card-leanings.fixture.spec.ts
+++ b/e2e/hover-card-leanings.fixture.spec.ts
@@ -154,6 +154,7 @@ for (const phone of [false, true]) {
await serve(page);
await page.goto("/");
await appCame(page);
+ await expect.poll(() => order(page)).toEqual(casts.map((c) => c.hash));
const before = await order(page);
expect(before).toEqual(casts.map((c) => c.hash));
@@ -195,7 +196,13 @@ for (const phone of [false, true]) {
// The next load of the feed takes the new order: alice's cast sinks.
await page.goto("/");
await appCame(page);
- await expect.poll(() => order(page)).toEqual([casts[1].hash, casts[2].hash, casts[0].hash]);
+ await expect.poll(async () => {
+ // Reading the open card and its screenshot can mark the feed seen.
+ // Restore its rows through the real control when this generation arrives.
+ const show = page.getByRole("button", { name: "Show seen posts", exact: true });
+ if (await show.isVisible()) await show.click();
+ return order(page);
+ }).toEqual([casts[1].hash, casts[2].hash, casts[0].hash]);
// And pressing again takes it back.
const again = await open(page, alice, phone);
diff --git a/e2e/kept-front.fixture.spec.ts b/e2e/kept-front.fixture.spec.ts
index d3154972..e7163ffb 100644
--- a/e2e/kept-front.fixture.spec.ts
+++ b/e2e/kept-front.fixture.spec.ts
@@ -83,17 +83,25 @@ for (const size of [
};
requestAnimationFrame(frame);
}, before.hash);
+ const frames = () => page.evaluate(() => (window as Window & { __frames?: Frame[] }).__frames!);
+ // The sampler must have the page being left, even when navigation
+ // can complete before its next frame on a fast retained route.
+ await expect.poll(async () => (await frames()).some((f) => f.path === "/" && f.top !== null),
+ { timeout: 3000, message: "a frame of the front page before leaving" }).toBe(true);
await page.locator(`#app .cast-card[data-hash="${before.hash}"] .cast-text`).click();
await expect(page.locator("#app .post")).toBeVisible();
// None of the front page is in the post's page.
await expect(page.locator("#app .cast-list .cast-card")).toHaveCount(0);
+ // DOM visibility can precede a paint. Record the post before Back,
+ // rather than letting both route changes fit between sampled frames.
+ await expect.poll(async () => (await frames()).findIndex((f) => f.path === `/p/${before.hash}` && f.top === null),
+ { timeout: 3000, message: "a frame of the post before Back" }).toBeGreaterThan(0);
await page.goBack();
await expect(page).toHaveURL(/\/$/);
const after = () => page.evaluate((hash) => {
const card = document.querySelector(`#app .cast-card[data-hash="${hash}"]`) as HTMLElement & { kept?: boolean };
return { same: card.kept === true, top: card.getBoundingClientRect().top, count: document.querySelectorAll("#app .cast-card").length };
}, before.hash);
- const frames = () => page.evaluate(() => (window as Window & { __frames?: Frame[] }).__frames!);
// Where the reader left the card: its last frame before the post. Not
// where it was before the click, which can scroll the page first: a
// card not yet still as Playwright checks it is scrolled into view
diff --git a/e2e/like-recast.fixture.spec.ts b/e2e/like-recast.fixture.spec.ts
index dbff18e4..2346f8d9 100644
--- a/e2e/like-recast.fixture.spec.ts
+++ b/e2e/like-recast.fixture.spec.ts
@@ -444,6 +444,10 @@ test("an approval from another account is refused, and signing out forgets the k
});
test("the real Worker endorses keys only for a session, from Allthing, and draws only Farcaster's links", async ({ request, baseURL }) => {
+ // Development has no custody secret. A deployment that endorses keys
+ // explicitly opts into the opposite exact readiness contract.
+ const readiness = process.env.E2E_EXPECT_WRITES_READY ?? "false";
+ expect(readiness, "E2E_EXPECT_WRITES_READY must be true or false").toMatch(/^(true|false)$/);
const writes = await request.get("/api/v1/signer");
expect(writes.status()).toBe(401);
expect(writes.headers()["cache-control"]).toBe("no-store");
@@ -477,5 +481,10 @@ test("the real Worker endorses keys only for a session, from Allthing, and draws
expect(refused.status(), url).toBe(400);
}
const status = await (await request.get("/api/v1/status")).json();
- expect(status.writes).toEqual({ ready: false });
+ expect(status.writes).toEqual(readiness === "true" ? {
+ ready: true,
+ app_fid: 3356318,
+ address: expect.stringMatching(/^0x[0-9a-fA-F]{40}$/),
+ sponsored: expect.any(Boolean),
+ } : { ready: false });
});
diff --git a/e2e/mini-apps.fixture.spec.ts b/e2e/mini-apps.fixture.spec.ts
index edf16f3c..93fdbd0b 100644
--- a/e2e/mini-apps.fixture.spec.ts
+++ b/e2e/mini-apps.fixture.spec.ts
@@ -124,6 +124,56 @@ for (const size of sizes) {
await expect(withPlain.locator(".mini-app-link, .mini-app-badge")).toHaveCount(0);
});
+ test("a direct mini app body follows Settings both ways without a reload", async ({ page }) => {
+ await serve(page);
+ let reads = 0;
+ const direct = { ...app, embeds: [{ kind: "link", url: "https://game.example/", title: "Game details" }] };
+ await page.route("**/api/v1/cast?*", (route) => {
+ reads++;
+ const selected = new URL(route.request().url()).searchParams.get("hash") === app.hash ? direct : cast(1, "An ordinary post");
+ return route.fulfill({ json: { thread: { cast: selected, children: [] }, quotes: [{ hash: plainQuoted.hash, cast: plainQuoted }], notes: {}, meta } });
+ });
+ await page.goto(`/p/${app.hash}`);
+ const post = page.locator("#app .post");
+ await expect(post.locator(".mini-app-note")).toBeVisible();
+ await expect(post.locator(".post-text, .link-card")).toHaveCount(0);
+ // Keep the real mini app's escape link even while its body is hidden.
+ await expect(post.locator(".mini-app-link")).toHaveCount(1);
+ for (const on of [true, false, true]) {
+ const { toggle } = await settings(page);
+ await toggle.click();
+ await expect(toggle).toHaveAttribute("aria-checked", String(on));
+ await page.keyboard.press("Escape");
+ if (on) {
+ await expect(post.locator(".post-text")).toHaveText("Play my game", { timeout: 2000 });
+ await expect(post.locator(".link-card")).toContainText("Game details");
+ await expect(post.locator("blockquote.quote")).toContainText("A plain quoted cast");
+ await expect(post.locator(".mini-app-note")).toHaveCount(0);
+ } else {
+ await expect(post.locator(".post-text, .link-card, blockquote.quote")).toHaveCount(0);
+ await expect(post.locator(".mini-app-note")).toBeVisible();
+ }
+ }
+ expect(reads, "changing Settings uses the carried post, without refetching it").toBe(1);
+ await page.goto(`/p/${hash(1)}`);
+ const ordinaryBody = post.locator(".post-text");
+ const ordinaryQuoteBody = post.locator("blockquote.quote .quote-text");
+ await expect(ordinaryBody).toHaveText("An ordinary post");
+ await expect(ordinaryQuoteBody).toHaveText("A plain quoted cast");
+ await ordinaryBody.evaluate((element) => (window as any).__ordinaryBody = element);
+ await ordinaryQuoteBody.evaluate((element) => (window as any).__ordinaryQuoteBody = element);
+ for (const _ of [false, true]) {
+ await (await settings(page)).toggle.click();
+ await page.keyboard.press("Escape");
+ await expect(ordinaryBody).toHaveText("An ordinary post");
+ expect(await ordinaryBody.evaluate((element) => element === (window as any).__ordinaryBody)).toBe(true);
+ await expect(ordinaryQuoteBody).toHaveText("A plain quoted cast");
+ expect(await ordinaryQuoteBody.evaluate((element) => element === (window as any).__ordinaryQuoteBody)).toBe(true);
+ await expect(post.locator(".mini-app-note, .mini-app-link")).toHaveCount(0);
+ }
+ expect(reads).toBe(2);
+ });
+
test("a thread leaves out mini app replies unless they are shown", async ({ page }) => {
await serve(page);
await page.goto(`/p/${hash(1)}`);
diff --git a/e2e/perf-diagnostics.fixture.spec.ts b/e2e/perf-diagnostics.fixture.spec.ts
new file mode 100644
index 00000000..d5daceac
--- /dev/null
+++ b/e2e/perf-diagnostics.fixture.spec.ts
@@ -0,0 +1,33 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+import { expect, test } from "@playwright/test";
+import { readFileSync } from "node:fs";
+import { perfDiagnosticsProbe, perfResponses, readPerfDiagnostics, safePerfUrl } from "./helpers/perf-diagnostics";
+
+test("cold-load diagnostics retain LCP, resource phases and cache evidence without URL credentials or queries", async ({ page }) => {
+ expect(safePerfUrl("https://reader:password@example.com:8443/a/b?token=secret#private")).toBe("https://example.com:8443/a/b");
+ expect(safePerfUrl("data:image/png;base64,private")).toBe("data:[omitted]");
+ expect(safePerfUrl("not a URL")).toBe("[invalid URL]");
+ const responses = perfResponses(page);
+ await page.addInitScript(perfDiagnosticsProbe);
+ await page.route("**/__perfdiag?*", (route) => route.fulfill({ contentType: "text/html", body: '
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);