Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ Tests must control the ordering they assert, not depend on runner speed.
applied layout, or observed event. Visible initial content does not prove that
background work finished. Register event observers before triggering actions.
- Wait for observable conditions with retrying assertions, not fixed sleeps or
immediate snapshots of asynchronous effects. Negative assertions need a
immediate snapshots of asynchronous effects (lint rejects `waitForTimeout`). Negative assertions need a
completion barrier proving the work that could violate them has finished.
Scope selectors to the semantic content being tested, not unrelated UI.
- When elapsed time is the behavior under test (expiry, debounce, retry), use a
Expand Down
4 changes: 4 additions & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@
"includes": ["**", "!tests/browser/page-errors.mjs"],
"plugins": ["./tests/browser/page-errors.grit"]
},
{
"includes": ["**"],
"plugins": ["./tests/browser/fixed-sleeps.grit"]
},
{
"includes": ["src/shared/design-system/icons/**"],
"linter": {
Expand Down
33 changes: 27 additions & 6 deletions tests/browser/avatar-loading.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,39 @@ test("shared avatars defer offscreen artwork, omit the referrer and recover from
contentType: "image/png",
});
});
let sentinelRequests = 0;
await page.route("https://images.example/sentinel.png", (route) => {
sentinelRequests++;
return route.fulfill({ status: 204 });
});
await page.goto(
"/tests/fixtures/agents.html?external-avatar&offscreen-avatar",
);
await expect(
page.getByRole("region", { name: "Agents", exact: true }),
).not.toBeInViewport();
await page.waitForTimeout(300);
const avatar = page
.getByRole("region", { name: "Library identities", exact: true })
.getByRole("img", { name: /^A Brain identity/ })
.first();
const image = avatar.locator("img");
// The library cards mount after an asynchronous refresh. The offscreen
// avatar must exist with its source before the barrier can say anything
// about whether it was deferred.
await expect(image).toHaveAttribute(
"src",
"https://images.example/avatar.png",
);
// Barrier: a lazy image added on screen now is requested only after the
// browser has evaluated lazy loading for the page, including the avatar.
await page.evaluate(() => {
const sentinel = document.createElement("img");
sentinel.loading = "lazy";
sentinel.alt = "";
sentinel.src = "https://images.example/sentinel.png";
document.body.prepend(sentinel);
});
await expect.poll(() => sentinelRequests).toBe(1);
expect(requests).toHaveLength(0);
await page.evaluate(() =>
window.scrollTo(0, document.documentElement.scrollHeight),
Expand All @@ -31,11 +57,6 @@ test("shared avatars defer offscreen artwork, omit the referrer and recover from
await expect.poll(() => requests.length).toBe(1);
expect(requests[0].referer).toBeUndefined();

const avatar = page
.getByRole("region", { name: "Library identities", exact: true })
.getByRole("img", { name: /^A Brain identity/ })
.first();
const image = avatar.locator("img");
await expect
.poll(() => image.evaluate((el) => el.naturalWidth))
.toBeGreaterThan(0);
Expand Down
8 changes: 8 additions & 0 deletions tests/browser/fixed-sleeps.grit
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
language js

// A fixed wait is not a synchronization primitive. Wait for an observable
// condition, hold the operation with a gate, or control the clock
// (page.clock). See AGENTS.md "Deterministic tests".
`$page.waitForTimeout($ms)` where {
register_diagnostic(span=$ms, message="Do not wait a fixed time. Wait for a condition, hold the work with a gate, or use page.clock.", severity="error")
}
11 changes: 10 additions & 1 deletion tests/browser/history-loading.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,17 @@ tall(
).toHaveCount(0);
await history(page).hover();
for (let i = 0; i < 100 && !app.pending.length; i++) {
const before = await history(page).evaluate((e) => e.scrollTop);
await page.mouse.wheel(0, -450);
await page.waitForTimeout(40);
// Each wheel step must land (scroll moves or the older page is
// requested) before the next one.
await expect
.poll(
async () =>
app.pending.length > 0 ||
(await history(page).evaluate((e) => e.scrollTop)) < before,
)
.toBe(true);
if (
await history(page)
.getByRole("button", { name: "Loading older…", exact: true })
Expand Down
33 changes: 32 additions & 1 deletion tests/browser/image-scroll.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,14 @@ async function holdDecodes(page, holdVisibility = false) {
});
};
const observers = [];
// Record native observe() calls on canvases so a test can wait for the
// product's passive effect to register before adding its own observer.
const observedCanvases = new WeakSet();
const realObserve = IntersectionObserver.prototype.observe;
IntersectionObserver.prototype.observe = function (target) {
if (target instanceof HTMLCanvasElement) observedCanvases.add(target);
return realObserve.call(this, target);
};
if (holdVisibility) {
window.IntersectionObserver = class {
constructor(callback) {
Expand All @@ -277,6 +285,7 @@ async function holdDecodes(page, holdVisibility = false) {
}
window.imageTest = {
paints,
observed: (canvas) => observedCanvases.has(canvas),
waiting: (name) => pending.has(`https://image.test/${name}.svg`),
release(name) {
const key = `https://image.test/${name}.svg`;
Expand Down Expand Up @@ -339,7 +348,29 @@ test("blurhash visibility, decode swap, failure and retired source lifetimes", a
`http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/attachment-image.html`,
);
// Mounted but offscreen (as in a long thread) must not spend pixel work.
await page.waitForTimeout(150);
// The product observes the canvas in a passive effect, so wait for that
// registration first. Barrier: an IntersectionObserver created after the
// product's delivers its first notification in the same or a later task,
// so the product's initial visibility callback has already run.
await expect
.poll(() =>
frame(page)
.locator("canvas")
.evaluate((canvas) => window.imageTest.observed(canvas)),
)
.toBe(true);
await frame(page)
Comment thread
loganj marked this conversation as resolved.
.locator("canvas")
.evaluate(
(canvas) =>
new Promise((resolve) => {
const observer = new IntersectionObserver(() => {
observer.disconnect();
resolve();
});
observer.observe(canvas);
}),
);
expect(await page.evaluate(() => window.imageTest.paints)).toEqual([]);
await page.getByRole("button", { name: "Reveal", exact: true }).click();
await expect
Expand Down
11 changes: 7 additions & 4 deletions tests/browser/layout.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,9 @@ test("narrow link panels begin after the rendered sidebar", async ({
readingTest(
"panel resizing preserves bottom follow and the visible reading anchor",
async ({ page, app }) => {
// Virtua ends an imperative scroll on a 150ms timer. The clock lets the
// test expire it exactly, instead of sleeping past it on wall time.
await page.clock.install();
await open(page, app);
await settle(page);
await link(page, app, "https://github.com/block/buzz/pull/4");
Expand All @@ -418,15 +421,15 @@ readingTest(
await settle(page);
await expectBottom();
// Late layout-only reflow must not need another message or viewport resize.
// Let Virtua's 150ms imperative-scroll scheduler expire first. Change actual
// Expire Virtua's 150ms imperative-scroll scheduler first. Change actual
// row layout, not scroll methods/metrics or the production observer callback.
await page.waitForTimeout(250);
await page.clock.runFor(150);
const lateLayout = await page.addStyleTag({
content: `[data-message-id="${received.id}"] p { padding-bottom: 120px; }`,
});
await settle(page);
await expectBottom();
await page.waitForTimeout(250);
await page.clock.runFor(150);
await lateLayout.evaluate((element) => element.remove());
await settle(page);
await expectBottom();
Expand Down Expand Up @@ -470,7 +473,7 @@ readingTest(
await expectAnchor(page, saved);
// Reflow can arrive after Virtua's 150ms imperative-scroll scheduler ends.
// Keep the selected reading anchor, not the partially clipped row above it.
await page.waitForTimeout(250);
await page.clock.runFor(150);
const preceding = await history.evaluate((element, id) => {
const rows = [...element.querySelectorAll("[data-message-id]")];
const index = rows.findIndex((row) => row.dataset.messageId === id);
Expand Down
2 changes: 1 addition & 1 deletion tests/browser/navigation-groups.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -757,9 +757,9 @@ test("Projects → Messages keeps saved groups, selected channel, and scroll on
await expect(
page.getByRole("textbox", { name: "Message #Beta", exact: true }),
).toBeVisible();
await page.waitForTimeout(300); // Keep the decode path held for the full interval.
// Wall time does not guarantee RAF callbacks on a busy runner. Wait for
// samples, not correct samples: every earlier frame stays in the assertion.
// The decode path stays held until finally, across all sampled frames.
await page.waitForFunction(() => window.sidebarFrames.length > 3);
const frames = await page.evaluate(() => {
window.captureSidebar = false;
Expand Down
15 changes: 12 additions & 3 deletions tests/browser/sidebar-unread.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -251,17 +251,26 @@ test("edge pills follow scroll and reveal the nearest unread without selection o
"data-channel-id",
"alpha",
);
// Barrier: focusing the revealed row prepares it and requests its head.
// The owner allows one speculative read with no backlog, so the next reveal
// may legitimately skip its own read while this one is in flight.
await expect
.poll(() =>
heads(app)
.slice(before)
.map(({ filter }) => filter["#h"][0]),
)
.toContain("dm-030");
await cue(page, "below").focus();
await cue(page, "below").press("Enter");
await expect.poll(() => inView(page, "dm-090")).toBe(true);
await expect(cue(page, "below")).toHaveCount(0);
expect(await list(page).boundingBox()).toEqual(size); // Overlay never resizes the list.
await page.waitForTimeout(1000);
const warmed = heads(app)
.slice(before)
.map(({ filter }) => filter["#h"][0]);
// Background roster warmth reads each channel once, serially. Scroll and cue
// interactions never add a repeated read on top of it.
// A revealed channel is read at most once. Scroll and cue interactions never
// add a repeated read.
expect(new Set(warmed).size).toBe(warmed.length);
expect(app.report.readPublications).toEqual([]);
expect(
Expand Down
18 changes: 15 additions & 3 deletions tests/browser/thread-unread.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ test("thread buttons show observed unread independently, clear only after readin
page,
app,
}, testInfo) => {
// Reading needs a 750ms dwell (use-reading.ts). The clock runs that deadline
// exactly where the test proves that something is not reading.
await page.clock.install();
await open(page, app);
const roots = app.histories
.get("primary/alpha")
Expand Down Expand Up @@ -209,7 +212,7 @@ test("thread buttons show observed unread independently, clear only after readin
exact: true,
});
await replyComposer.focus();
await page.waitForTimeout(1000);
await page.clock.runFor(750);
await expect(replyComposer).toBeFocused();
await expect(first).toHaveAccessibleName(/Observed unread replies/); // Click/composer focus is not reading.
await history.focus();
Expand Down Expand Up @@ -245,8 +248,17 @@ test("thread buttons show observed unread independently, clear only after readin
await panel
.getByRole("button", { name: "Close thread", exact: true })
.click();
app.reply(roots[0].id, true);
await page.waitForTimeout(1000);
const own = app.reply(roots[0].id, true);
// Barrier: the session has indexed the reply, so its unread effect is final.
await expect
.poll(() =>
page.evaluate(
(id) =>
window.fixtureRelay.snapshot().session.unread.attention("alpha", id),
own.id,
),
)
.toMatchObject({ status: "ineligible", unread: false });
await expect(first).toHaveAccessibleName("View thread: 23 replies");
app.reply(roots[0].id);
await expect(first).toHaveAccessibleName(/Observed unread replies/);
Expand Down
Loading