diff --git a/CHANGELOG.md b/CHANGELOG.md index 10d4890d..3c7aba03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,14 @@ All notable changes to Allthing are recorded here. The format follows [Keep a Ch - Videos up to 250 MB upload in 8 MB chunks. Retry continues from the last acknowledged chunk; Remove cancels the pending upload. Preparation and finishing have visible states, and the cast stays disabled until its attachments are ready. - Compatible MP4 and MOV videos are prepared for HLS from bounded metadata and file slices, preserving their frames and sound without copying the entire video into WebAssembly. Existing image limits and storage safeguards remain. +### Fixed + +- After Escape closes a hover card, layout changes cannot reopen it under a stationary pointer. Moving away and back still opens it normally. + +### Development + +- Browser checks wait for app handover after measuring the first painted card, compare Back with the position at the actual click, and await exact page-load counts. Local media checks run database commands asynchronously so CI's Node 22 client can retire closed connections, and retain bounded Worker diagnostics on failure. Existing timing limits, request counts and upload assertions remain. + ## [0.5.1] - 2026-10-23 ### Changed diff --git a/crates/allthing-web/src/components/popover.rs b/crates/allthing-web/src/components/popover.rs index 0e39accf..a04e9b0a 100644 --- a/crates/allthing-web/src/components/popover.rs +++ b/crates/allthing-web/src/components/popover.rs @@ -119,6 +119,12 @@ pub fn Popover( ) -> impl IntoView { let state = RwSignal::new(State::Closed); let timer = StoredValue::new(None::); + // Layout can send trusted leave/enter events to a stationary pointer + // after Escape's closing animation. Only a new pointer excursion or + // an explicit activation may arm that hover again. + let hover_dismissed = RwSignal::new(false); + let pointer_at = StoredValue::new(None::<(i32, i32)>); + let dismissed_at = StoredValue::new(None::<(i32, i32)>); let root = NodeRef::::new(); let button = NodeRef::::new(); let max_px = max_px.unwrap_or(MAX_PANEL_PX); @@ -258,6 +264,7 @@ pub fn Popover( let open_by = move |press: bool| { cancel(); place(); + hover_dismissed.set(false); pressed_open.set_value(press); state.set(State::Open); }; @@ -282,6 +289,13 @@ pub fn Popover( ); } }; + let dismiss_hover = move || { + if state.try_get_untracked() == Some(State::Open) { + dismissed_at.set_value(pointer_at.get_value()); + hover_dismissed.set(true); + close(); + } + }; if let Some(shut) = shut { Effect::new(move |seen: Option| { let now = shut.get(); @@ -315,9 +329,10 @@ pub fn Popover( warm.run(()); } }; - let enter = move |_: ev::MouseEvent| { + let enter = move |ev: ev::MouseEvent| { + pointer_at.set_value(Some((ev.client_x(), ev.client_y()))); warm(); - if hovering() { + if hovering() && !hover_dismissed.get_untracked() { match state.get_untracked() { State::Open => cancel(), State::Closed => schedule(open_delay, Box::new(open)), @@ -328,6 +343,11 @@ pub fn Popover( } } }; + let pointer_move = move |ev: ev::PointerEvent| { + if ev.pointer_type() == "mouse" || ev.pointer_type() == "pen" && can_hover() { + pointer_at.set_value(Some((ev.client_x(), ev.client_y()))); + } + }; let leave = move |_: ev::MouseEvent| { if hovering() { if state.get_untracked() != State::Open { @@ -431,7 +451,7 @@ pub fn Popover( pointer.set_value(String::new()); if ev.key() == "Escape" && state.get_untracked() == State::Open { ev.stop_propagation(); - close(); + dismiss_hover(); if links { // Back to the link focus rested on, or else the first one. let back = focused_link.get_value().filter(|link| link.is_connected()).or_else(|| { @@ -463,6 +483,7 @@ pub fn Popover( let outside_click = StoredValue::new(None::); let escape_key = StoredValue::new(None::); let pointer_up = StoredValue::new(None::); + let hover_rearm = StoredValue::new(None::); // The window's capture listeners: light dismissal's, and a fixed panel's. let captures = StoredValue::new_local(Vec::::new()); let remove_listeners = move || { @@ -484,6 +505,26 @@ pub fn Popover( }); !inside_root(target) && !over }; + Effect::new(move |_| { + hover_rearm.update_value(|current| { + if let Some(handle) = current.take() { + handle.remove(); + } + }); + if !hover_dismissed.get() { + return; + } + hover_rearm.set_value(Some(window_event_listener(ev::pointermove, move |ev| { + let moved = dismissed_at.get_value().is_none_or(|point| point != (ev.client_x(), ev.client_y())); + let outside = ev + .target() + .and_then(|target| target.dyn_into::().ok()) + .is_some_and(|target| !inside_root(&target)); + if (ev.pointer_type() == "mouse" || ev.pointer_type() == "pen" && can_hover()) && moved && outside { + let _ = hover_dismissed.try_set(false); + } + }))); + }); Effect::new(move |_| { remove_listeners(); if state.get() != State::Open { @@ -520,7 +561,7 @@ pub fn Popover( } escape_key.set_value(Some(window_event_listener(ev::keydown, move |ev| { if ev.key() == "Escape" { - close(); + dismiss_hover(); } }))); pointer_up.set_value(Some(window_event_listener(ev::pointerup, move |ev| { @@ -595,6 +636,11 @@ pub fn Popover( on_cleanup(move || { cancel(); remove_listeners(); + hover_rearm.update_value(|current| { + if let Some(handle) = current.take() { + handle.remove(); + } + }); }); let id = format!("pop-{}", next_id()); @@ -653,6 +699,7 @@ pub fn Popover( on:focusout=focus_out on:keydown=keydown on:pointerdown=pointer_down + on:pointermove=pointer_move on:click=link_click > {lead.map(|lead| lead.run())} diff --git a/e2e/allthing.spec.ts b/e2e/allthing.spec.ts index fb735042..28675e09 100644 --- a/e2e/allthing.spec.ts +++ b/e2e/allthing.spec.ts @@ -123,6 +123,9 @@ test.describe("front page", () => { // The static copy builds the page in the browser (several upstream round trips, no edge cache). expect(elapsed, `first casts after ${elapsed} ms`).toBeLessThan(PAGES ? 6000 : LIVE ? 3000 : 5000); + // First paint above keeps its original timing budget. Interactive + // branding is checked after the app replaces its early boot header. + await appCame(page); // Branding: the wordmark only, no logo mark. await expect(page.locator("header .wordmark")).toHaveText("Allthing"); await expect(page.locator(".site-header img, .wordmark svg, .logo-mark")).toHaveCount(0); @@ -789,6 +792,7 @@ test.describe("threads and navigation", () => { test("opening a post from the feed renders at once, and back restores the scroll position", async ({ page }) => { await page.goto(at("/")); + await appCame(page); await expect(firstCard(page)).toBeVisible(); await page.evaluate(() => window.scrollTo(0, 1600)); await page.waitForTimeout(300); @@ -804,30 +808,48 @@ test.describe("threads and navigation", () => { // estimated height, and images above it load: measure once the card has // stopped moving. await page.waitForLoadState("networkidle").catch(() => {}); - let before = await top(); + let settledTop = await top(); await expect .poll(async () => { - const [was, now] = [before, (before = await top())]; + const [was, now] = [settledTop, (settledTop = await top())]; return was === now; }, { intervals: [250] }) .toBe(true); - // Measure in the page: from the click to the post's text in the DOM. - await page.evaluate(() => { + // Playwright may scroll again while making the click actionable. As + // in kept-front.fixture.spec.ts, Back must restore the actual departure + // position, not the earlier, possibly off-screen layout measurement. + // The capture-phase point is also when nav.rs saves the reader's place. + // Measure from this same real click to the post's text in the DOM. + await page.evaluate((hash) => { (window as any).__openedIn = null; + (window as any).__openedFrom = null; document.addEventListener("click", () => { const t0 = performance.now(); + const card = document.querySelector(`.cast-card[data-hash="${hash}"]`); + (window as any).__openedFrom = { + hash, path: location.pathname + location.search, + top: card ? Math.round(card.getBoundingClientRect().top) : null, + y: Math.round(scrollY), viewport: innerHeight, + }; const check = () => document.querySelector(".post .post-text, .post .mini-app-note") ? ((window as any).__openedIn = performance.now() - t0) : requestAnimationFrame(check); requestAnimationFrame(check); }, { capture: true, once: true }); - }); + }, hash); await card.locator(".cast-text").click({ position: { x: 4, y: 8 } }); await expect(page).toHaveURL(new RegExp(`/p/${hash}$`)); await expect.poll(() => page.evaluate(() => (window as any).__openedIn)).not.toBeNull(); const openedIn = await page.evaluate(() => (window as any).__openedIn as number); console.log(`post opened in ${openedIn.toFixed(0)} ms`); expect(openedIn, "post renders from feed data").toBeLessThan(100); + const departed = await page.evaluate(() => (window as any).__openedFrom as { + hash: string; path: string; top: number | null; y: number; viewport: number; + } | null); + expect(departed?.hash, "the real click's source card").toBe(hash); + expect(departed?.path, "the real click leaves Home").toBe(at("/")); + expect(departed?.top, "a laid-out source card at departure").not.toBeNull(); + const before = departed!.top!; await page.goBack(); await expect(left).toBeVisible(); diff --git a/e2e/hover-cards.fixture.spec.ts b/e2e/hover-cards.fixture.spec.ts index bacc6632..132d99bb 100644 --- a/e2e/hover-cards.fixture.spec.ts +++ b/e2e/hover-cards.fixture.spec.ts @@ -86,9 +86,10 @@ async function watchDismissal(page: Page) { const listeners = new Set(); const describe = (target: EventTarget | null) => target instanceof Element ? `${target.tagName.toLowerCase()}.${target.className}`.slice(0, 120) : null; - const record = (action: string, target: EventTarget | null = null) => { + const record = (action: string, target: EventTarget | null = null, event?: Event) => { events.push({ at: Math.round(performance.now()), action, target: describe(target), + point: event instanceof MouseEvent ? [event.clientX, event.clientY] : null, focus: describe(document.activeElement), windowKeys: listeners.size, panels: [...document.querySelectorAll(".popover-panel")].slice(0, 8) .map((panel) => ({ id: panel.id, class: panel.className })), @@ -108,9 +109,9 @@ async function watchDismissal(page: Page) { for (const type of ["keydown", "keyup", "mouseenter", "mouseleave", "focusin", "focusout"]) { add(type, ((event: Event) => { if (event instanceof KeyboardEvent) { - if (event.key === "Escape") record(`${type}:${event.key}:${event.isTrusted}`, event.target); + if (event.key === "Escape") record(`${type}:${event.key}:${event.isTrusted}`, event.target, event); } else if (event.target instanceof Element && event.target.matches(".popover, .popover-panel, .user-link, .author, .display-name")) { - record(type, event.target); + record(type, event.target, event); } }) as EventListener, true); } @@ -237,6 +238,61 @@ test("Escape stays dismissed when the pointer re-enters during the closing anima await expect(profile).toBeVisible(); }); +test("Escape stays dismissed across stationary-pointer layout changes after closing", async ({ page }) => { + await serve(page); + await watchDismissal(page); + await page.goto("/"); + await appCame(page); + const picture = chipLink(feedCard(page), "user-link"); + const chip = picture.locator(".."); + const profile = profileOf(page, "Alice", "alice"); + await picture.hover(); + await expect(profile).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(profile).toHaveCount(0); + + // The pointer stays still. Moving its trigger away and back creates + // real layout-driven boundary events after Closing has already ended. + // Those trusted events must not undo the reader's Escape dismissal. + type Boundary = { action: string; target: string | null; point: [number, number] | null }; + const boundaries = () => page.evaluate(() => + ((window as Window & { popoverDismissalEvents?: Boundary[] }).popoverDismissalEvents ?? []) + .filter((event) => ["mouseenter", "mouseleave"].includes(event.action) && event.target === "span.popover user-chip")); + const before = await boundaries(); + const entered = before.filter((event) => event.action === "mouseenter").length; + const left = before.filter((event) => event.action === "mouseleave").length; + const parked = before.at(-1)!.point; + await chip.evaluate((element) => { (element as HTMLElement).style.transform = "translateX(400px)"; }); + await expect.poll(async () => (await boundaries()).filter((event) => event.action === "mouseleave").length).toBe(left + 1); + await chip.evaluate((element) => { (element as HTMLElement).style.transform = ""; }); + await expect.poll(async () => (await boundaries()).filter((event) => event.action === "mouseenter").length).toBe(entered + 1); + expect((await boundaries()).slice(-2).map((event) => event.point)).toEqual([parked, parked]); + await page.waitForTimeout(300); + try { + await expect(profile).toHaveCount(0, { timeout: 500 }); + } catch (error) { + const events = await page.evaluate(() => (window as Window & { popoverDismissalEvents?: unknown[] }).popoverDismissalEvents); + await test.info().attach("popover-dismissal-events", { body: JSON.stringify(events, null, 2), contentType: "application/json" }); + throw error; + } + + // Deliberate movement out and back still starts a fresh hover. + await page.mouse.move(5, 5); + await picture.hover(); + await expect(profile).toBeVisible(); + + // A hover-capable pen follows the same path; touch keeps its explicit + // tap behavior. Chromium delivers real pen pointer events through CDP. + await page.keyboard.press("Escape"); + await expect(profile).toHaveCount(0); + const box = (await picture.boundingBox())!; + const input = await page.context().newCDPSession(page); + await input.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: 5, y: 5, pointerType: "pen" }); + await input.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: box.x + box.width / 2, y: box.y + box.height / 2, pointerType: "pen" }); + await expect(profile).toBeVisible({ timeout: 500 }); + await input.detach(); +}); + test("a card's picture shows the small copy the feed loaded until its own has loaded", async ({ page }) => { await serve(page); // Alice has a picture. The feed's small copy comes at once; the card's diff --git a/e2e/media-upload.fixture.spec.ts b/e2e/media-upload.fixture.spec.ts index aff80c78..32e0a428 100644 --- a/e2e/media-upload.fixture.spec.ts +++ b/e2e/media-upload.fixture.spec.ts @@ -1,10 +1,11 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import { execFileSync, spawn, type ChildProcess } from "node:child_process"; +import { execFile, spawn, type ChildProcess } from "node:child_process"; import { createHmac } from "node:crypto"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { deflateSync } from "node:zlib"; +import { promisify } from "node:util"; import { expect, test } from "@playwright/test"; /* @@ -32,6 +33,15 @@ let worker: ChildProcess | undefined; let folder = ""; let config = ""; let lastUploadRequestAt = 0; +const execute = promisify(execFile); +const LOG_BYTES = 64 * 1024; +let workerStdout = Buffer.alloc(0); +let workerStderr = Buffer.alloc(0); + +/** Preserve bounded private Worker output so transport failures show its errors. */ +function keepLog(previous: Buffer, next: Buffer): Buffer { + return Buffer.from(Buffer.concat([previous, next]).subarray(-LOG_BYTES)); +} /** A session cookie as the Worker's sign-in sets it: `..`. */ function session(fid: number) { @@ -67,14 +77,17 @@ function absolute(toml: string) { .replace(/^migrations_dir = "(.+)"$/m, (_, dir) => `migrations_dir = ${JSON.stringify(path.join(ROOT, dir))}`); } -/** A query on the spec's local D1, as the Worker sees it. */ -function d1(sql: string): Record[] { - const out = execFileSync( +/** A query on the spec's local D1, as the Worker sees it. + * Leave Node's event loop running: on Node 22, blocking CLI work can hide + * a closed idle fetch socket until the next upload tries to reuse it. + */ +async function d1(sql: string): Promise[]> { + const { stdout } = await execute( "npx", ["wrangler", "d1", "execute", "allthing-index", "--local", "--config", config, "--persist-to", path.join(folder, "state"), "--json", "--command", sql], - { cwd: ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }, + { cwd: ROOT, encoding: "utf8" }, ); - return JSON.parse(out)[0].results; + return JSON.parse(stdout)[0].results; } /** A PNG of `width` × 1 pixels, real enough for any viewer. */ @@ -128,13 +141,22 @@ test.beforeAll(async () => { worker = spawn( "npx", ["wrangler", "dev", "--config", config, "--port", String(PORT), "--ip", "127.0.0.1", "--persist-to", path.join(folder, "state"), "--var", `SESSION_SECRET:${SECRET}`], - { cwd: ROOT, stdio: "ignore", detached: true }, + { cwd: ROOT, stdio: ["ignore", "pipe", "pipe"], detached: true }, ); + worker.stdout?.on("data", (chunk: Buffer) => { workerStdout = keepLog(workerStdout, chunk); }); + worker.stderr?.on("data", (chunk: Buffer) => { workerStderr = keepLog(workerStderr, chunk); }); await expect .poll(async () => (await fetch(`${ORIGIN}/api/v1/status`).catch(() => null))?.status ?? 0, { timeout: 120_000, intervals: [1_000] }) .toBe(200); }); +test.afterEach(async ({}, info) => { + if (info.status !== info.expectedStatus) { + await info.attach("media-private-worker-stdout", { body: workerStdout, contentType: "text/plain" }); + await info.attach("media-private-worker-stderr", { body: workerStderr, contentType: "text/plain" }); + } +}); + test.afterAll(async () => { if (worker?.pid) process.kill(-worker.pid, "SIGTERM"); if (folder) rmSync(folder, { recursive: true, force: true }); @@ -231,9 +253,9 @@ test("a reader uploads an image and a video, and anyone gets them back byte for // D1 counts each file once R2 has it: three, and their bytes, in the // reader's rows, the total and the day's count. const bytes = image.length + 2 * video.length; - expect(d1("SELECT COUNT(*) AS files, SUM(bytes) AS bytes FROM media WHERE fid = 1234")).toEqual([{ files: 3, bytes }]); - expect(d1("SELECT bytes, files FROM media_total WHERE name = 'all'")).toEqual([{ bytes, files: 3 }]); - expect(d1("SELECT SUM(bytes) AS bytes, SUM(files) AS files FROM media_total WHERE name LIKE 'd%'")).toEqual([{ bytes, files: 3 }]); + expect(await d1("SELECT COUNT(*) AS files, SUM(bytes) AS bytes FROM media WHERE fid = 1234")).toEqual([{ files: 3, bytes }]); + expect(await d1("SELECT bytes, files FROM media_total WHERE name = 'all'")).toEqual([{ bytes, files: 3 }]); + expect(await d1("SELECT SUM(bytes) AS bytes, SUM(files) AS files FROM media_total WHERE name LIKE 'd%'")).toEqual([{ bytes, files: 3 }]); }); test("a video remuxed for HLS is embedded as its playlist, which the Worker writes from R2's metadata, for the Farcaster app", async () => { @@ -305,7 +327,7 @@ test("a video remuxed for HLS is embedded as its playlist, which the Worker writ expect((await fetch(`${ORIGIN}/m/4321/${"0".repeat(32)}.m3u8`)).status).toBe(404); // An index that isn't the file's, or with anything but an MP4, is refused, and nothing is kept. - const [{ files }] = d1("SELECT COUNT(*) AS files FROM media WHERE fid = 4321"); + const [{ files }] = await d1("SELECT COUNT(*) AS files FROM media WHERE fid = 4321"); for (const [body, type, line] of [ [video, "video/mp4", "1;64x36;avc1.64000a;1258;4000:19463"], [video, "video/mp4", "1;64x36;avc1\",x=\"y;1258;4000:19463,4000:18474,4000:18667"], @@ -317,7 +339,7 @@ test("a video remuxed for HLS is embedded as its playlist, which the Worker writ expect(refused.status, line).toBe(400); expect(refused.json.error).toBe("That video's stream index doesn't match it; try again"); } - expect(d1("SELECT COUNT(*) AS files FROM media WHERE fid = 4321")).toEqual([{ files }]); + expect(await d1("SELECT COUNT(*) AS files FROM media WHERE fid = 4321")).toEqual([{ files }]); }); test("a file is served only as the type it was declared as, and never runs as a page", async ({ page }) => { @@ -380,7 +402,7 @@ test("uploads are refused clearly: their type, size, session, origin and pace", expect((await upload(image, "image/png", 9)).status, "another reader has their own").toBe(200); // Nothing refused was kept. - expect(d1("SELECT COUNT(*) AS files FROM media WHERE fid = 7")).toEqual([{ files: 0 }]); + expect(await d1("SELECT COUNT(*) AS files FROM media WHERE fid = 7")).toEqual([{ files: 0 }]); }); test("quotas: 60 files a reader a day, 2,000 uploads a day in all, and 9 GiB stored", async () => { @@ -388,37 +410,37 @@ test("quotas: 60 files a reader a day, 2,000 uploads a day in all, and 9 GiB sto // A reader with 60 files in the past day may add no more until the // oldest is a day old. const values = Array.from({ length: 60 }, (_, i) => `('11/${i.toString(16).padStart(32, "0")}.png', 11, 10, 'image/png', ${now() - 3_600 + i})`); - d1(`INSERT INTO media (key, fid, bytes, type, created_at) VALUES ${values.join(", ")}`); + await d1(`INSERT INTO media (key, fid, bytes, type, created_at) VALUES ${values.join(", ")}`); const full = await upload(image, "image/png", 11); expect(full.status).toBe(507); expect(full.json.error).toBe("You've attached 60 files in the past 24 hours, the most a day takes"); expect(Number(full.headers.get("retry-after"))).toBeGreaterThan(82_000); // Files older than a day don't count. - d1(`UPDATE media SET created_at = created_at - 86400 WHERE fid = 11 AND created_at < ${now() - 3_000}`); + await d1(`UPDATE media SET created_at = created_at - 86400 WHERE fid = 11 AND created_at < ${now() - 3_000}`); expect((await upload(image, "image/png", 11)).status).toBe(200); // 2,000 uploads a UTC day from everyone, then none until midnight. const day = Math.floor(now() / 86_400); const days = `('d${day}', 0, 2000), ('d${day + 1}', 0, 2000)`; - const [{ files }] = d1(`SELECT COALESCE(SUM(files), 0) AS files FROM media_total WHERE name = 'd${day}'`); - d1(`INSERT INTO media_total (name, bytes, files) VALUES ${days} ON CONFLICT (name) DO UPDATE SET files = 2000`); + const [{ files }] = await d1(`SELECT COALESCE(SUM(files), 0) AS files FROM media_total WHERE name = 'd${day}'`); + await d1(`INSERT INTO media_total (name, bytes, files) VALUES ${days} ON CONFLICT (name) DO UPDATE SET files = 2000`); const busy = await upload(image, "image/png", 13); expect(busy.status).toBe(507); expect(busy.json.error).toBe("Allthing has taken all the attachments it can today; try again tomorrow"); expect(Number(busy.headers.get("retry-after"))).toBeLessThanOrEqual(86_400); - d1(`UPDATE media_total SET files = ${files} WHERE name = 'd${day}'`); - d1(`DELETE FROM media_total WHERE name = 'd${day + 1}'`); + await d1(`UPDATE media_total SET files = ${files} WHERE name = 'd${day}'`); + await d1(`DELETE FROM media_total WHERE name = 'd${day + 1}'`); expect((await upload(image, "image/png", 13)).status).toBe(200); // Everyone's files together stay under R2's free 10 GB-month: 9 GiB. - const [{ bytes }] = d1("SELECT bytes FROM media_total WHERE name = 'all'"); - d1(`UPDATE media_total SET bytes = ${9 * 1024 * MIB - image.length + 1} WHERE name = 'all'`); + const [{ bytes }] = await d1("SELECT bytes FROM media_total WHERE name = 'all'"); + await d1(`UPDATE media_total SET bytes = ${9 * 1024 * MIB - image.length + 1} WHERE name = 'all'`); const over = await upload(image, "image/png", 12); expect(over.status).toBe(507); expect(over.json.error).toBe("Allthing has no room for more attachments just now"); - d1(`UPDATE media_total SET bytes = ${9 * 1024 * MIB - image.length} WHERE name = 'all'`); + await d1(`UPDATE media_total SET bytes = ${9 * 1024 * MIB - image.length} WHERE name = 'all'`); expect((await upload(image, "image/png", 12)).status, "a file that fills it exactly").toBe(200); - d1(`UPDATE media_total SET bytes = ${bytes} WHERE name = 'all'`); + await d1(`UPDATE media_total SET bytes = ${bytes} WHERE name = 'all'`); }); type Part = { part_number: number; etag: string }; @@ -453,23 +475,23 @@ test("a large video uses bounded parts, resumes a lost response, and completes e expect(created.json).toEqual({ id, part_bytes: chunkBytes }); // Losing the create response doesn't reserve or create another upload. expect((await videoControl("create", fid, asked)).json).toEqual(created.json); - expect(d1(`SELECT COUNT(*) AS n FROM media_uploads WHERE fid = ${fid} AND state <> 'done'`)).toEqual([{ n: 1 }]); + expect(await d1(`SELECT COUNT(*) AS n FROM media_uploads WHERE fid = ${fid} AND state <> 'done'`)).toEqual([{ n: 1 }]); const first = await videoControl("part", fid, bytes.subarray(0, chunkBytes), id, 1); expect(first.status, JSON.stringify(first.json)).toBe(200); const day = Math.floor(now() / 86_400); - const before = d1(`SELECT operations FROM media_operations WHERE day = ${day}`); + const before = await d1(`SELECT operations FROM media_operations WHERE day = ${day}`); const replay = await videoControl("part", fid, bytes.subarray(0, chunkBytes), id, 1); expect(replay.json).toEqual(first.json); - expect(d1(`SELECT operations FROM media_operations WHERE day = ${day}`)).toEqual(before); + expect(await d1(`SELECT operations FROM media_operations WHERE day = ${day}`)).toEqual(before); const last = await videoControl("part", fid, bytes.subarray(chunkBytes), id, 2); expect(last.status, JSON.stringify(last.json)).toBe(200); const parts: Part[] = [first.json, last.json]; - await info.attach("multipart-manifest-diagnostic", { body: JSON.stringify({ parts, stored: d1(`SELECT bytes,parts FROM media_uploads WHERE fid = ${fid}`) }), contentType: "application/json" }); + await info.attach("multipart-manifest-diagnostic", { body: JSON.stringify({ parts, stored: await d1(`SELECT bytes,parts FROM media_uploads WHERE fid = ${fid}`) }), contentType: "application/json" }); const complete = await videoControl("complete", fid, { parts }, id); expect(complete.status, JSON.stringify(complete.json)).toBe(200); expect(complete.json.url).toMatch(/\.m3u8$/); expect((await videoControl("complete", fid, { parts }, id)).json).toEqual(complete.json); - expect(d1(`SELECT COUNT(*) AS n, SUM(bytes) AS bytes FROM media WHERE fid = ${fid}`)).toEqual([{ n: 1, bytes: bytes.length }]); + expect(await d1(`SELECT COUNT(*) AS n, SUM(bytes) AS bytes FROM media WHERE fid = ${fid}`)).toEqual([{ n: 1, bytes: bytes.length }]); const mp4 = local(complete.json.url.replace(/\.m3u8$/, ".mp4")); expect(Buffer.from(await (await fetch(mp4)).arrayBuffer()).equals(bytes)).toBe(true); const range = await fetch(mp4, { headers: { range: `bytes=${chunkBytes - 12}-${chunkBytes + 11}` } }); @@ -481,7 +503,7 @@ test("a large video uses bounded parts, resumes a lost response, and completes e expect((await fetch(mp4, { method: "HEAD" })).status).toBe(200); // Once short-lived replay state/tombstones expire, its public immutable // key still cannot be reused to overwrite a cast's existing attachment. - d1(`DELETE FROM media_uploads WHERE id = '${id}'; DELETE FROM media_upload_cancellations WHERE id = '${id}'`); + await d1(`DELETE FROM media_uploads WHERE id = '${id}'; DELETE FROM media_upload_cancellations WHERE id = '${id}'`); expect((await videoControl("create", fid, asked)).status).toBe(409); expect(Buffer.from(await (await fetch(mp4)).arrayBuffer()).equals(bytes)).toBe(true); }); @@ -505,9 +527,9 @@ test("multipart control verifies ownership, descriptors, exact part lengths and expect(first.status, JSON.stringify(first.json)).toBe(200); expect((await videoControl("complete", fid, { parts: [{ part_number: 1, etag: "invented" }] }, id)).status).toBe(400); expect((await videoControl("abort", fid + 10, undefined, id)).status).toBe(204); - expect(d1(`SELECT COUNT(*) AS n FROM media_uploads WHERE id = '${id}' AND fid = ${fid}`)).toEqual([{ n: 1 }]); + expect(await d1(`SELECT COUNT(*) AS n FROM media_uploads WHERE id = '${id}' AND fid = ${fid}`)).toEqual([{ n: 1 }]); expect((await videoControl("abort", fid, undefined, id)).status).toBe(204); - expect(d1(`SELECT COUNT(*) AS n FROM media_uploads WHERE id = '${id}'`)).toEqual([{ n: 0 }]); + expect(await d1(`SELECT COUNT(*) AS n FROM media_uploads WHERE id = '${id}'`)).toEqual([{ n: 0 }]); expect((await videoControl("abort", fid, undefined, id)).status).toBe(204); expect((await videoControl("create", fid, asked)).status).toBe(410); }); @@ -518,10 +540,10 @@ test("pending videos reserve quotas atomically, including legacy uploads, until const b = { ...a, id: chunkId(5032) }; const results = await Promise.all([videoControl("create", fid, a), videoControl("create", fid, b)]); expect(results.map(r => r.status).sort()).toEqual([200, 507]); - expect(d1(`SELECT SUM(bytes) AS bytes FROM media_uploads WHERE fid = ${fid} AND state <> 'done'`)).toEqual([{ bytes: 200 * MIB }]); + expect(await d1(`SELECT SUM(bytes) AS bytes FROM media_uploads WHERE fid = ${fid} AND state <> 'done'`)).toEqual([{ bytes: 200 * MIB }]); // An expired session still reserves its bytes while it has R2 parts. const id = results[0].status === 200 ? a.id : b.id; - d1(`UPDATE media_uploads SET expires_at = ${now() - 1} WHERE id = '${id}'`); + await d1(`UPDATE media_uploads SET expires_at = ${now() - 1} WHERE id = '${id}'`); expect((await videoControl("part", fid, Buffer.alloc(chunkBytes), id, 1)).status).toBe(410); expect((await videoControl("create", fid, { ...a, id: chunkId(5033), bytes: 101 * MIB })).status).toBe(507); expect((await videoControl("abort", fid, undefined, id)).status).toBe(204); @@ -540,20 +562,20 @@ test("removal before create stays cancelled, and every R2 attempt has a daily wo const openId = chunkId(5041); expect((await videoControl("create", fid, { ...asked, id: openId })).status).toBe(200); const day = Math.floor(now() / 86_400); - const [{ operations }] = d1(`SELECT operations FROM media_operations WHERE day = ${day}`); - d1(`UPDATE media_operations SET operations = 1000 WHERE day = ${day}`); + const [{ operations }] = await d1(`SELECT operations FROM media_operations WHERE day = ${day}`); + await d1(`UPDATE media_operations SET operations = 1000 WHERE day = ${day}`); try { expect((await videoControl("part", fid, Buffer.alloc(chunkBytes), openId, 1)).status).toBe(503); expect((await videoControl("create", fid + 1, { ...asked, id: chunkId(5042) })).status).toBe(503); - expect(d1(`SELECT COUNT(*) AS n FROM media_uploads WHERE fid = ${fid + 1}`)).toEqual([{ n: 0 }]); + expect(await d1(`SELECT COUNT(*) AS n FROM media_uploads WHERE fid = ${fid + 1}`)).toEqual([{ n: 0 }]); // Aborting a known upload is never blocked by exhausted write work. expect((await videoControl("abort", fid, undefined, openId)).status).toBe(204); - expect(d1(`SELECT cancel_requested FROM media_uploads WHERE id = '${openId}'`)).toEqual([{ cancel_requested: 1 }]); + expect(await d1(`SELECT cancel_requested FROM media_uploads WHERE id = '${openId}'`)).toEqual([{ cancel_requested: 1 }]); } finally { - d1(`UPDATE media_operations SET operations = ${operations} WHERE day = ${day}`); + await d1(`UPDATE media_operations SET operations = ${operations} WHERE day = ${day}`); } expect((await videoControl("abort", fid, undefined, openId)).status).toBe(204); - expect(d1(`SELECT COUNT(*) AS n FROM media_uploads WHERE id = '${openId}'`)).toEqual([{ n: 0 }]); + expect(await d1(`SELECT COUNT(*) AS n FROM media_uploads WHERE id = '${openId}'`)).toEqual([{ n: 0 }]); }); test("cancelling an in-flight lease fences completion, and the pending pool is bounded", async () => { @@ -563,14 +585,14 @@ test("cancelling an in-flight lease fences completion, and the pending pool is b expect(part.status, JSON.stringify(part.json)).toBe(200); // A request still receiving bytes holds a lease. Removal marks it now, // while the lease keeps R2 cleanup from racing its active I/O. - d1(`UPDATE media_uploads SET lock_id = 'held-request', lock_until = ${now() + 600} WHERE id = '${id}'`); + await d1(`UPDATE media_uploads SET lock_id = 'held-request', lock_until = ${now() + 600} WHERE id = '${id}'`); expect((await videoControl("abort", fid, undefined, id)).status).toBe(409); - expect(d1(`SELECT cancel_requested FROM media_uploads WHERE id = '${id}'`)).toEqual([{ cancel_requested: 1 }]); + expect(await d1(`SELECT cancel_requested FROM media_uploads WHERE id = '${id}'`)).toEqual([{ cancel_requested: 1 }]); expect((await videoControl("complete", fid, { parts: [part.json] }, id)).status).toBe(410); expect((await videoControl("part", fid, body, id, 1)).status).toBe(410); - d1(`UPDATE media_uploads SET lock_until = 0 WHERE id = '${id}'`); + await d1(`UPDATE media_uploads SET lock_until = 0 WHERE id = '${id}'`); expect((await videoControl("abort", fid, undefined, id)).status).toBe(204); - expect(d1(`SELECT COUNT(*) AS n FROM media_uploads WHERE id = '${id}'`)).toEqual([{ n: 0 }]); + expect(await d1(`SELECT COUNT(*) AS n FROM media_uploads WHERE id = '${id}'`)).toEqual([{ n: 0 }]); // Seed only reservations: no R2 object or part is fabricated. Aggregate // quota reads cannot grow with arbitrarily many tiny pending uploads. @@ -578,12 +600,12 @@ test("cancelling an in-flight lease fences completion, and the pending pool is b const name = chunkId(800000 + i); return `('${name}', '700/${name}.mp4', 700, 1, 'video/mp4', 'single', ${now()}, ${now() + 3600})`; }); - d1(`INSERT INTO media_uploads (id,key,fid,bytes,type,mode,created_at,expires_at) VALUES ${seed.join(",")}`); + await d1(`INSERT INTO media_uploads (id,key,fid,bytes,type,mode,created_at,expires_at) VALUES ${seed.join(",")}`); try { expect((await videoControl("create", 507, { id: chunkId(507), type: "video/mp4", bytes: 1 })).status).toBe(409); - expect(d1("SELECT COUNT(*) AS n FROM media_uploads WHERE state <> 'done'")).toEqual([{ n: 128 }]); + expect(await d1("SELECT COUNT(*) AS n FROM media_uploads WHERE state <> 'done'")).toEqual([{ n: 128 }]); } finally { - d1("DELETE FROM media_uploads WHERE fid = 700"); + await d1("DELETE FROM media_uploads WHERE fid = 700"); } }); @@ -655,8 +677,8 @@ test("a 250 MiB video completes all 32 parts with bounded buffers and exact acco expect(got.status).toBe(206); expect(Array.from(new Uint8Array(await got.arrayBuffer()))).toEqual(expected); } - expect(d1(`SELECT COUNT(*) AS n, SUM(bytes) AS bytes FROM media WHERE fid = ${fid}`)).toEqual([{ n: 1, bytes: total }]); - expect(d1(`SELECT COUNT(*) AS n FROM media_uploads WHERE fid = ${fid} AND state <> 'done'`)).toEqual([{ n: 0 }]); + expect(await d1(`SELECT COUNT(*) AS n, SUM(bytes) AS bytes FROM media WHERE fid = ${fid}`)).toEqual([{ n: 1, bytes: total }]); + expect(await d1(`SELECT COUNT(*) AS n FROM media_uploads WHERE fid = ${fid} AND state <> 'done'`)).toEqual([{ n: 0 }]); expect((await videoControl("complete", fid, { parts }, id)).json).toEqual(completed.json); - expect(d1(`SELECT COUNT(*) AS n FROM media WHERE fid = ${fid}`)).toEqual([{ n: 1 }]); + expect(await d1(`SELECT COUNT(*) AS n FROM media WHERE fid = ${fid}`)).toEqual([{ n: 1 }]); }); diff --git a/e2e/split-parts.fixture.spec.ts b/e2e/split-parts.fixture.spec.ts index 62a32d4b..0b44b942 100644 --- a/e2e/split-parts.fixture.spec.ts +++ b/e2e/split-parts.fixture.spec.ts @@ -85,14 +85,15 @@ test("a part that can't be fetched loads the page again once, then offers to try await page.goto("/dau"); const alert = page.getByRole("alert"); await expect(alert).toContainText("This part of Allthing couldn't load"); - expect(loads).toBe(2); + // The app can render before the document's load event has arrived. + await expect.poll(() => loads).toBe(2); await expect(page.locator(".dau-meter")).toHaveCount(0); blocked = false; await alert.getByRole("button", { name: "Try again" }).click(); await expect(page.locator(".dau-meter")).toContainText("6,174"); await expect(page.getByRole("alert")).toHaveCount(0); - expect(loads).toBe(3); + await expect.poll(() => loads).toBe(3); expect(errors).toEqual([]); });