diff --git a/AGENTS.md b/AGENTS.md index 312a496..3cc1f23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,7 +83,8 @@ Do not infer layout, native editing or cross-window correctness from a DOM emula When moving coverage, map removed assertions to replacements and demonstrate that the replacement catches the regression before deleting the browser case. -Generate only the fixture data the test needs. Share immutable builds and stateless +Make minimal fixture data the default and opt into larger datasets only for an +explicit scale, pagination or geometry contract. Share immutable builds and stateless servers, never mutable test state, identities or browser contexts. Preserve large datasets and isolated runners when scale or performance is the behavior under test. Record browser cases added/removed, their browser-only justification, replacement diff --git a/src/features/messages/Messages.module.css b/src/features/messages/Messages.module.css index 373070e..3b8c083 100644 --- a/src/features/messages/Messages.module.css +++ b/src/features/messages/Messages.module.css @@ -16,6 +16,8 @@ overflow-y: auto; overflow-anchor: none; padding: 0 var(--space-panel-inset); + /* Keep fractional last rows reachable despite integer scroll extents. */ + padding-bottom: var(--space-half); scrollbar-width: thin; } .feed ol { @@ -217,7 +219,7 @@ } @media (max-width: 650px) { .feed { - padding: 0 var(--space-3); + padding-inline: var(--space-3); } .composer { margin: var(--space-2) var(--space-3) var(--space-3); diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs index 3a265bf..56c06e4 100644 --- a/tests/browser/fixture.mjs +++ b/tests/browser/fixture.mjs @@ -35,7 +35,7 @@ export const test = base.extend({ dmLabels: [false, { option: true }], tallMessages: [false, { option: true }], membershipActivity: [false, { option: true }], - historyCounts: [{ alpha: historySize, beta: 80 }, { option: true }], + historyCounts: [{ alpha: 1, beta: 1 }, { option: true }], developmentReact: [false, { option: true, scope: "worker" }], pluginFixtures: [false, { option: true, scope: "worker" }], compiledApp: [buildApp, { scope: "worker" }], diff --git a/tests/browser/image-scroll.spec.mjs b/tests/browser/image-scroll.spec.mjs index 77d9d86..ea7fbd5 100644 --- a/tests/browser/image-scroll.spec.mjs +++ b/tests/browser/image-scroll.spec.mjs @@ -76,7 +76,7 @@ test("delayed and failed images preserve bottom and reading anchors across remou // Routing deliberately disables HTTP cache: each remount can load late. await new Promise((resolve) => setTimeout(resolve, 150)); await route.fulfill( - url.endsWith("/96.svg") + url.endsWith("/56.svg") ? { status: 404, body: "missing" } : { contentType: "image/svg+xml", @@ -123,6 +123,9 @@ test("delayed and failed images preserve bottom and reading anchors across remou await settle(page); expect(await gap()).toBeLessThan(4); expect(await feed.evaluate((el) => el.scrollHeight)).toBe(before); + const failedImage = feed.locator('img[src="https://image.test/56.svg"]'); + await expect(failedImage).toHaveCSS("visibility", "hidden"); + await expect(failedImage.locator("..").locator("canvas")).toBeVisible(); // Reading above bottom survives decode; this must not be a force-bottom fix. held = true; pending.clear(); diff --git a/tests/browser/initial-position.spec.mjs b/tests/browser/initial-position.spec.mjs index e7d46d8..f3d8203 100644 --- a/tests/browser/initial-position.spec.mjs +++ b/tests/browser/initial-position.spec.mjs @@ -1,7 +1,12 @@ import { test, expect } from "./fixture.mjs"; import { open, settle, anchor, expectAnchor } from "./timeline.mjs"; -test.use({ productionBroker: true, developmentReact: true }); +test.use({ + productionBroker: true, + developmentReact: true, +}); +const sized = test.extend({ historyCounts: { alpha: 20, beta: 20 } }); +const short = test.extend({ historyCounts: { alpha: 20, beta: 1 } }); const history = (page) => page.getByRole("region", { name: "Channel message history" }); async function bottom(page, app, label) { @@ -35,49 +40,52 @@ async function select(page, name) { ).toBeVisible(); } -test("cold and warm sidebar entries start at bottom without scrolling", async ({ - page, - app, -}) => { - await open(page, app); - await bottom(page, app, "cold Alpha"); - await select(page, "Beta"); - await bottom(page, app, "cold Beta"); - await select(page, "Alpha"); - await bottom(page, app, "warm Alpha"); - await page.getByRole("button", { name: "Home", exact: true }).first().click(); - await page.reload(); - await page - .getByRole("button", { name: "Messages", exact: true }) - .first() - .click(); - await bottom(page, app, "persisted bottom Alpha"); - await select(page, "Beta"); - await bottom(page, app, "persisted bottom Beta"); -}); +sized( + "cold and warm sidebar entries start at bottom without scrolling", + async ({ page, app }) => { + await open(page, app); + await bottom(page, app, "cold Alpha"); + await select(page, "Beta"); + await bottom(page, app, "cold Beta"); + await select(page, "Alpha"); + await bottom(page, app, "warm Alpha"); + await page + .getByRole("button", { name: "Home", exact: true }) + .first() + .click(); + await page.reload(); + await page + .getByRole("button", { name: "Messages", exact: true }) + .first() + .click(); + await bottom(page, app, "persisted bottom Alpha"); + await select(page, "Beta"); + await bottom(page, app, "persisted bottom Beta"); + }, +); -test("short channel with tall messages opens at the actual bottom", async ({ - page, - app, -}) => { - app.histories.set( - "primary/beta", - app.histories.get("primary/beta").slice(0, 1), - ); - app.append("primary", "beta", "Tall message\n".repeat(70), false); - app.append("primary", "beta", "Last message\n".repeat(25), false); - await open(page, app); - await bottom(page, app, "cold Alpha control"); - await select(page, "Beta"); - await bottom(page, app, "cold tall Beta"); - await select(page, "Alpha"); - await select(page, "Beta"); - await bottom(page, app, "warm tall Beta"); -}); +short( + "short channel with tall messages opens at the actual bottom", + async ({ page, app }) => { + expect(app.histories.get("primary/beta")).toHaveLength(1); + app.append("primary", "beta", "Tall message\n".repeat(70), false); + app.append("primary", "beta", "Last message\n".repeat(25), false); + await open(page, app); + await bottom(page, app, "cold Alpha control"); + await select(page, "Beta"); + await bottom(page, app, "cold tall Beta"); + await select(page, "Alpha"); + await select(page, "Beta"); + await bottom(page, app, "warm tall Beta"); + }, +); // This case tests reading restoration, not cancellation of held older pages. // Taller rows keep its gesture outside prefetch; paging has separate journeys. -const readingTest = test.extend({ tallMessages: true }); +const readingTest = test.extend({ + tallMessages: true, + historyCounts: { alpha: 20, beta: 1 }, +}); readingTest( "deliberate reading anchor survives a session reload", async ({ page, app }) => { @@ -116,19 +124,19 @@ readingTest( ); for (const count of [1, 3, 5, 7, 9]) { - test(`near-fit channel with ${count} mixed-height rows starts at bottom`, async ({ - page, - app, - }) => { - app.histories.set( - "primary/beta", - app.histories.get("primary/beta").slice(0, count), - ); - await open(page, app); - await select(page, "Beta"); - await bottom(page, app, `cold ${count}-row Beta`); - await select(page, "Alpha"); - await select(page, "Beta"); - await bottom(page, app, `warm ${count}-row Beta`); + const nearFitTest = test.extend({ + historyCounts: { alpha: 1, beta: count }, }); + nearFitTest( + `near-fit channel with ${count} mixed-height rows starts at bottom`, + async ({ page, app }) => { + await open(page, app); + expect(app.histories.get("primary/beta")).toHaveLength(count); + await select(page, "Beta"); + await bottom(page, app, `cold ${count}-row Beta`); + await select(page, "Alpha"); + await select(page, "Beta"); + await bottom(page, app, `warm ${count}-row Beta`); + }, + ); } diff --git a/tests/browser/layout.spec.mjs b/tests/browser/layout.spec.mjs index 04a8a7c..a53a598 100644 --- a/tests/browser/layout.spec.mjs +++ b/tests/browser/layout.spec.mjs @@ -1,9 +1,12 @@ import { test, expect } from "./fixture.mjs"; import { settle, upper, expectAnchor } from "./timeline.mjs"; +const scroll = test.extend({ historyCounts: { alpha: 20, beta: 1 } }); // Resize tests must not enter the fixture’s deliberately held paging path. -test.use({ historyCounts: { alpha: 640, beta: 80 } }); -const readingTest = test.extend({ tallMessages: true }); +const readingTest = test.extend({ + tallMessages: true, + historyCounts: { alpha: 20, beta: 1 }, +}); async function expectNonPaging(page, app) { expect( await page @@ -81,47 +84,49 @@ async function shellFits(page, width) { } } -test("page overscroll is disabled while message history still scrolls", async ({ - page, - app, -}) => { - await open(page, app); - // Headless wheel input does not reproduce macOS trackpad rubber-banding. - // Check the viewport policy as well as real panel scrolling and shell bounds. - await expect(page.locator("html")).toHaveCSS("overscroll-behavior", "none"); - const shell = page.locator(".shell-background"); - const bounds = await box(shell); - const history = page.getByRole("region", { name: "Channel message history" }); - await settle(page); - const initialOffset = await history.evaluate((el) => el.scrollTop); - await history.hover(); - await page.mouse.wheel(0, -300); - await expect - .poll(() => history.evaluate((el) => el.scrollTop)) - .toBeLessThan(initialOffset - 100); - await settle(page); - expect(await box(shell)).toEqual(bounds); - - // Projects has no overflowing content: gestures must leave the shell in place. - await page - .getByRole("navigation", { name: "Pages", exact: true }) - .getByRole("button", { name: "Projects", exact: true }) - .click(); - await page.getByRole("heading", { name: "Projects", exact: true }).hover(); - for (const [x, y] of [ - [0, -600], - [0, 600], - [-600, 0], - [600, 0], - ]) { - await page.mouse.wheel(x, y); - await page.evaluate(() => new Promise(requestAnimationFrame)); +scroll( + "page overscroll is disabled while message history still scrolls", + async ({ page, app }) => { + await open(page, app); + // Headless wheel input does not reproduce macOS trackpad rubber-banding. + // Check the viewport policy as well as real panel scrolling and shell bounds. + await expect(page.locator("html")).toHaveCSS("overscroll-behavior", "none"); + const shell = page.locator(".shell-background"); + const bounds = await box(shell); + const history = page.getByRole("region", { + name: "Channel message history", + }); + await settle(page); + const initialOffset = await history.evaluate((el) => el.scrollTop); + await history.hover(); + await page.mouse.wheel(0, -300); + await expect + .poll(() => history.evaluate((el) => el.scrollTop)) + .toBeLessThan(initialOffset - 100); + await settle(page); expect(await box(shell)).toEqual(bounds); - expect(await page.evaluate(() => [window.scrollX, window.scrollY])).toEqual( - [0, 0], - ); - } -}); + + // Projects has no overflowing content: gestures must leave the shell in place. + await page + .getByRole("navigation", { name: "Pages", exact: true }) + .getByRole("button", { name: "Projects", exact: true }) + .click(); + await page.getByRole("heading", { name: "Projects", exact: true }).hover(); + for (const [x, y] of [ + [0, -600], + [0, 600], + [-600, 0], + [600, 0], + ]) { + await page.mouse.wheel(x, y); + await page.evaluate(() => new Promise(requestAnimationFrame)); + expect(await box(shell)).toEqual(bounds); + expect( + await page.evaluate(() => [window.scrollX, window.scrollY]), + ).toEqual([0, 0]); + } + }, +); test("bento surfaces, centered tabs, real link panel and compact community navigation", async ({ page, diff --git a/tests/browser/message-navigation.spec.mjs b/tests/browser/message-navigation.spec.mjs index e3e0460..dbea090 100644 --- a/tests/browser/message-navigation.spec.mjs +++ b/tests/browser/message-navigation.spec.mjs @@ -4,7 +4,7 @@ import { open, end, settle } from "./timeline.mjs"; test.use({ pluginFixtures: true, exactMessages: true, - historyCounts: { alpha: 120, beta: 0 }, + historyCounts: { alpha: 103, beta: 0 }, }); const thread = (page) => page.getByRole("region", { name: "Thread messages", exact: true }); diff --git a/tests/browser/notifications.spec.mjs b/tests/browser/notifications.spec.mjs index 323f83f..02f3e47 100644 --- a/tests/browser/notifications.spec.mjs +++ b/tests/browser/notifications.spec.mjs @@ -1,5 +1,5 @@ import { test, expect } from "./fixture.mjs"; -import { open, settle } from "./timeline.mjs"; +import { end, open, settle } from "./timeline.mjs"; import { finalizeEvent, generateSecretKey } from "nostr-tools"; test.use({ @@ -255,6 +255,12 @@ for (const kind of ["mention", "thread reply"]) { page, app, }) => { + // Only this geometry scenario needs an overflowing Beta history. + if (kind === "mention") { + for (let i = 0; i < 20; i++) { + app.append("primary", "beta", `Earlier message ${i}`, false, false); + } + } // Model a real prior contribution in relay history, not a client-side // participation/readiness override. The incoming reply itself has no p tag. const root = @@ -343,6 +349,24 @@ for (const kind of ["mention", "thread reply"]) { ), ) .toBe(false); + if (!root) { + // Fractional reflow must not leave the last row clipped at maximum scroll. + await row.evaluate((element) => { + element.style.paddingBottom = "0.125px"; + }); + for (const width of [1440, 640]) { + await page.setViewportSize({ width, height: 950 }); + await expect + .poll(() => + surface.evaluate( + (element) => element.scrollHeight > element.clientHeight, + ), + ) + .toBe(true); + await end(page); + await expect(row).toBeInViewport({ ratio: 1 }); + } + } }); } diff --git a/tests/fixtures/image-scroll.tsx b/tests/fixtures/image-scroll.tsx index 1284efd..9e24f18 100644 --- a/tests/fixtures/image-scroll.tsx +++ b/tests/fixtures/image-scroll.tsx @@ -8,7 +8,7 @@ import { keypair, message } from "../../src/features/relay/testing"; import "../../src/shared/styles/globals.css"; const viewer = keypair(), relay = keypair(); -const events = Array.from({ length: 100 }, (_, i) => +const events = Array.from({ length: 60 }, (_, i) => message( viewer, "images",