mirror of
https://github.com/block/buzz.git
synced 2026-11-02 07:22:55 +00:00
## Summary Test-side fixes for ten desktop E2E flake clusters and six long-tail rows from the 21–28 Oct 2026 CI audit. Each spec now waits for the state it assumes (a rendered row, a mounted listener, a finished animation, a held boot read), not a fixed delay or a one-shot probe. Two mock-bridge seams support this. The PR changes test code and mock-bridge seams only, apart from one product constant extracted with its value unchanged. It is one of seven PRs split out of the `flakey-tests` branch (siblings below). ### Related issue None found. - #8622, #8244 and #8386 touch some of these spec files, but not the tests fixed here. - §7 mode A (`navigation:472`) is left to open PR #6852, which already sets the width and measures in one polled evaluate. - Draft #8279 queues cold-launch nostr-bind links in the product. Its mock returns null, so the §12 listener seam is still needed. ### Mock-bridge seams - `__BUZZ_E2E_EVENT_LISTENER_COUNT__(event)` counts live **app** listeners per Tauri event. It wraps the mocked `plugin:event|listen`/`unlisten`. The spec-facing `__TAURI_INTERNALS__.listen` passes a marker target that the wrapper skips, so a spec's own subscription can't satisfy the wait. Tauri 2.11.1's mock ignores targets, so delivery is unchanged. - `deferFirstChannelsRead` arms the existing channels-read hold at boot. `__BUZZ_E2E_RELEASE_CHANNELS_READ__` releases it. - `CREATE_PROJECT_REFOCUS_DELAY_MS` moves to `createProjectFormFocus.ts` with its value unchanged (50 ms). Both `CreateProjectFormContent` and `project-commit-detail.spec` import it. ### Flaky tests fixed | Test | Failing runs | Cause | Change | |---|---|---|---| | §2 `overscroll-boundary.spec.ts:34` (and its sibling) | 39601763602, 40367495044, 40583830861, 40597484657, 40373404267 | The `message-timeline` test id first lands on the loading skeleton, which doesn't overflow, so the scroll lock rightly cancels the wheel. | Wait for a rendered row, and content taller than the viewport, before dispatching wheels. | | §4 `project-conversation-load-failure.spec.ts:73` | 40367495044 (all 3 attempts), 40608383847, 40583830861; PRs 39541263507, 40509460168 | The seeded root and reply were dated `now`. The panel's live REQ (`since = floor(now)`) still replayed the reply, so the error card never appeared. | Date both events a minute in the past, and correct the comment. | | §6 `workflows.spec.ts` `createWorkflow` cluster (:222, :365, :297, :525, :471, :406, :614, :997) | 14 of 50 main jobs; :222 ×4, e.g. 40353619362 | `AnimatePresence mode="wait"` keeps the trigger body mounted through its exit, so "Message text" could fill the trigger's field (:222 and the callers). For :297, `isVisible()` read "closed" one frame before the dialog opened its own channel list. | Wait for the step body's "Step details" control before filling. Create dialogs wait for the list; only the duplicate dialog clicks it. | | §12 `nostr-bind.spec.ts` (:156, :198, :228, :247, :260, :335, :417, :447) | 8 main jobs, each test once | The spec emitted `deep-link-nostr-bind` before the app subscribed, and the mock drops events that have no listener. | Wait for `__BUZZ_E2E_EVENT_LISTENER_COUNT__` to report the app listener before emitting. | | §15 `profile.spec.ts:1128` (integration) | 7 of 50 main jobs | The 320 ms motion-sampling window started before the click. | Start the window when `activeTab` reads "runtime". | | §17 `agent-availability.spec.ts:602`, both shutdown-failure variants (integration) | 6 jobs (4 disconnected-online, 2 failed-online) | The "shutdown refused" toast sits over the Delete row and pauses while the pointer is on it, so the retry click is intercepted. | Assert the toast, then retry with focus + Enter. The toaster has no close button. | | §19 `virtualization.spec.ts:247` "08 — cascading older pages…" | 4 of 50 main jobs; PR 40605736093 (all 3 attempts) | The 120 ms and 400 ms windows were timed from before the wheel bursts, and CDP can deliver wheels late. | Count the wheels the page receives. Each window opens at the first wheel and closes only after all have landed, then waits for still frames. **A second, product-side mode remains** (see below). | | §20 `message-copy-link.spec.ts:160` | 4 of 50 main jobs (5 failed attempts), e.g. 40595483966 | The huddle row reused `"d".repeat(64)`, which is a seeded ordinary `#general` message. | Use ids that no seed uses, and emit only after `waitForMockChannelHeadReady`. | | §21 `navigation.spec.ts:687` | 4 of 50 main jobs | The pending chip's `boundingBox()` was read once, and can be null while the row mounts. | Poll until the chip has a width. | | §23 `sidebar-snapshot.spec.ts:358`, `:388`, `:438` | PR 40427822003 (all 3 attempts); never on main | The 500 ms loading-state budget counted app boot, so a slow runner finished the channels read first. | Hold the first channels read with `deferFirstChannelsRead`, assert the loading state, then release it. | | `project-commit-detail.spec.ts:259` | 1–2 CI failures (audit long tail) | A 50 ms refocus timer can land mid-fill and type the description into the name field. | Once the name field is focused, wait out a page timer of `CREATE_PROJECT_REFOCUS_DELAY_MS`; equal-delay timers fire in order. | | `project-pr-review.spec.ts:3520` | 1–2 CI failures | The same timer pattern in `CreateProjectWorkItemDialog`. | Wait for `toBeFocused()` on the title, which only that timer can satisfy. | | `thread-load-failure.spec.ts:79` | 1–2 CI failures | `openWelcomeThread` branched on a one-shot `count()` and fell back to a hover-only Reply click. | Wait for the summary row, then click it. | | `virtualization.spec.ts:201` | 1–2 CI failures | The drop point sat 2 px above the target's bottom edge, after two fixed frames. | Drop at the centre, after dnd-kit's live region names the target. | | `channel-composer-overflow.spec.ts:217` | 1–2 CI failures | Same-second emits sort by id, and the clearance was checked once. | `createdAt: base + i` and `expect.poll`. | | `scroll-history.spec.ts:1289` | 1–2 CI failures | A fixed 250 ms wait before idle sampling. | Poll for coverage first. | Local before/after counts are from the original `flakey-tests` commits (e286a6ae0, 816c7c35e, 67185df6a). Each used a forcing that made `main`'s copy fail with CI's signature: | Test | Before | After | |---|---|---| | §2 | 3/15 | 15/15 | | §4 | 0/10 | 10/10 | | §6 :222 | 0/3 | 3/3 | | §6 :297 | 0/3 | 3/3 | | §12 | 0/3 | 3/3 | | §15 | 0/3 | 3/3 | | §17 | 1/4 | 4/4 | | §19 late-wheel modes | 0/3 | 3/3 and 2/3 | | §23 | 2/6 | 6/6 | | project-commit-detail | 0/3 | 3/3 | | project-pr-review | 0/3 | 3/3 | §20, §21, `thread-load-failure:79`, `virtualization:201`, `channel-composer-overflow:217` and `scroll-history:1289` were not reproduced; they get the fix that matches their CI signature. Not fixed (follow-up): `virtualization:247` also has a product-side mode. In one frame after a prepend, `scrollHeight` can drop 458–639 px while `scrollTop` moves only 50–114 px, so the anchor row jumps 382–457 px. That is lost anchor compensation, and this test can still fail on it. ### Verification This branch was pushed without local re-verification, by the user's choice: local test runs were overloading the machine and failing for that reason. **Hosted CI is the check.** It was pushed with `git push --no-verify`, because the pre-push hook runs typecheck and the unit suites. #8613's flake-check lane will also repeat the changed specs. - Every file except `virtualization.spec.ts` is byte-identical to its final version on `flakey-tests` (67185df6a). That file carries only this PR's hunks; see "Merge order". - Checked for this PR: `git diff --check`, biome on the partial spec (no changes, the same as on `main`), and a conflict-free merge with all six siblings onto `main`. ### Readiness Test and mock-bridge change; the one product edit is a constant extraction with its value unchanged. AGENTS.md's readiness steps (agent review, then human confirmation) are still outstanding, so `buzz-review-completed` is not set. ### Merge order No required order. `desktop/tests/e2e/virtualization.spec.ts` is also edited by **flakey-tests-desktop-races**, which adds one test after line 929; this PR's hunks are all above line 551. They don't touch, so the two merge cleanly in either order, as checked with `git merge-tree`. ### Sibling PRs Split from `flakey-tests` (one PR per area): #8676 (PostgreSQL lease + lane), #8677 (relay E2E helper + Windows shell), #8678 (mobile), #8679 (Tauri apt cache + Windows Rust cap), #8680 (desktop product races), #8682 (desktop E2E composer + row actions). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Jitter <2b2e6415e748c35180847a37aadae06a11e30dddc76b784e7fd9354c4eb42e7a@buzz.block.builderlab.xyz>
202 lines
6.8 KiB
TypeScript
202 lines
6.8 KiB
TypeScript
import { expect, test, type Locator, type Page } from "@playwright/test";
|
|
|
|
import { KIND_HUDDLE_STARTED } from "../../src/shared/constants/kinds";
|
|
import { installMockBridge } from "../helpers/bridge";
|
|
import { waitForMockChannelHeadReady } from "../helpers/channelHeadReady";
|
|
|
|
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
|
|
|
|
async function latestClipboardWrite(page: Page) {
|
|
return page.evaluate(() =>
|
|
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).findLast(
|
|
({ command }) => command === "copy_text_to_clipboard",
|
|
),
|
|
);
|
|
}
|
|
|
|
async function expectCopyLinkUnavailable(row: Locator, messageId: string) {
|
|
await row.hover();
|
|
await expect(row.getByTestId(`copy-link-message-${messageId}`)).toHaveCount(
|
|
0,
|
|
);
|
|
|
|
const moreActions = row.getByTestId(`more-actions-${messageId}`);
|
|
if (await moreActions.count()) {
|
|
await moreActions.click({ force: true });
|
|
await expect(
|
|
row.page().getByTestId(`copy-message-link-${messageId}`),
|
|
).toHaveCount(0);
|
|
await row.page().keyboard.press("Escape");
|
|
}
|
|
}
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.context().grantPermissions(["clipboard-read", "clipboard-write"], {
|
|
origin: "http://127.0.0.1:4173",
|
|
});
|
|
await installMockBridge(page);
|
|
});
|
|
|
|
test("message action rail copies the same canonical thread link as More", async ({
|
|
page,
|
|
}) => {
|
|
await page.setViewportSize({ width: 900, height: 700 });
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
|
),
|
|
)
|
|
.toBe(true);
|
|
|
|
const { replyId, rootId } = await page.evaluate(() => {
|
|
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
|
if (!emit) throw new Error("Mock message emitter is unavailable.");
|
|
const root = emit({
|
|
channelName: "general",
|
|
content: "Copy-link regression root",
|
|
id: "a".repeat(64),
|
|
});
|
|
const reply = emit({
|
|
channelName: "general",
|
|
content: "Copy-link regression reply",
|
|
id: "b".repeat(64),
|
|
parentEventId: root.id,
|
|
});
|
|
return { replyId: reply.id, rootId: root.id };
|
|
});
|
|
|
|
await page
|
|
.locator(
|
|
`[data-testid="message-thread-summary"][data-thread-head-id="${rootId}"]`,
|
|
)
|
|
.click();
|
|
const threadPanel = page.getByTestId("message-thread-panel");
|
|
const replyRow = threadPanel.locator(`[data-message-id="${replyId}"]`);
|
|
await expect(replyRow).toContainText("Copy-link regression reply");
|
|
await replyRow.hover();
|
|
|
|
const actionBar = replyRow.getByTestId(`message-action-bar-${replyId}`);
|
|
const orderedActionNames = await actionBar
|
|
.getByRole("button")
|
|
.evaluateAll((buttons) =>
|
|
buttons.map((button) => button.getAttribute("aria-label")),
|
|
);
|
|
expect(orderedActionNames).toEqual([
|
|
"React with :+1:",
|
|
"React with :heart:",
|
|
"React with :joy:",
|
|
"Open reactions",
|
|
"Reply",
|
|
"Copy link",
|
|
"More actions",
|
|
]);
|
|
|
|
const pickerButton = actionBar.getByRole("button", {
|
|
name: "Open reactions",
|
|
});
|
|
await expect(pickerButton.locator("svg")).toHaveClass(/lucide-smile-plus/);
|
|
|
|
const divider = actionBar.getByTestId("message-action-divider");
|
|
await expect(divider).toBeVisible();
|
|
const [pickerBox, dividerBox, replyBox] = await Promise.all([
|
|
actionBar.getByRole("button", { name: "Open reactions" }).boundingBox(),
|
|
divider.boundingBox(),
|
|
actionBar.getByRole("button", { name: "Reply" }).boundingBox(),
|
|
]);
|
|
expect(pickerBox).not.toBeNull();
|
|
expect(dividerBox).not.toBeNull();
|
|
expect(replyBox).not.toBeNull();
|
|
if (!pickerBox || !dividerBox || !replyBox) {
|
|
throw new Error("Message action order bounds missing.");
|
|
}
|
|
expect(pickerBox.x + pickerBox.width).toBeLessThan(dividerBox.x);
|
|
expect(dividerBox.x + dividerBox.width).toBeLessThan(replyBox.x);
|
|
|
|
const copyLink = actionBar.getByTestId(`copy-link-message-${replyId}`);
|
|
await expect(copyLink).toHaveAccessibleName("Copy link");
|
|
await copyLink.hover();
|
|
await expect(page.getByRole("tooltip", { name: "Copy link" })).toBeVisible();
|
|
|
|
const expectedLink = `buzz://message?channel=${GENERAL_CHANNEL_ID}&id=${replyId}&thread=${rootId}`;
|
|
await copyLink.click();
|
|
await expect
|
|
.poll(async () => (await latestClipboardWrite(page))?.payload.text)
|
|
.toBe(expectedLink);
|
|
await expect(
|
|
page.locator("[data-sonner-toast]").filter({
|
|
hasText: "Link copied to clipboard",
|
|
}),
|
|
).toBeVisible();
|
|
|
|
await actionBar.getByTestId(`more-actions-${replyId}`).click();
|
|
await page.getByTestId(`copy-message-link-${replyId}`).click();
|
|
await expect
|
|
.poll(async () => {
|
|
const writes = (await page.evaluate(() =>
|
|
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
|
|
({ command }) => command === "copy_text_to_clipboard",
|
|
),
|
|
)) as Array<{ payload: unknown }>;
|
|
return writes.map(({ payload }) => payload);
|
|
})
|
|
.toEqual([{ text: expectedLink }, { text: expectedLink }]);
|
|
|
|
const [barBox, panelBox] = await Promise.all([
|
|
actionBar.boundingBox(),
|
|
threadPanel.boundingBox(),
|
|
]);
|
|
expect(barBox).not.toBeNull();
|
|
expect(panelBox).not.toBeNull();
|
|
if (!barBox || !panelBox) throw new Error("Message action bounds missing.");
|
|
expect(barBox.x).toBeGreaterThanOrEqual(panelBox.x);
|
|
expect(barBox.x + barBox.width).toBeLessThanOrEqual(
|
|
panelBox.x + panelBox.width,
|
|
);
|
|
});
|
|
|
|
test("pending and huddle rows omit both copy-link surfaces", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
// Emit only after history and its head refresh settle, so neither can
|
|
// replace the rows under test.
|
|
await waitForMockChannelHeadReady(page, "general", GENERAL_CHANNEL_ID);
|
|
|
|
// The ids must not match a seeded #general message. "d".repeat(64) is the
|
|
// seeded reaction target, which has a copy-link button of its own.
|
|
const { huddleId, pendingId } = await page.evaluate((huddleKind) => {
|
|
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
|
if (!emit) throw new Error("Mock message emitter is unavailable.");
|
|
const pending = emit({
|
|
channelName: "general",
|
|
content: "Pending copy-link regression",
|
|
id: "2e".repeat(32),
|
|
pending: true,
|
|
});
|
|
const huddle = emit({
|
|
channelName: "general",
|
|
content: JSON.stringify({
|
|
ephemeral_channel_id: "10000000-0000-4000-8000-000000000001",
|
|
}),
|
|
id: "1d".repeat(32),
|
|
kind: huddleKind,
|
|
});
|
|
return { huddleId: huddle.id, pendingId: pending.id };
|
|
}, KIND_HUDDLE_STARTED);
|
|
|
|
await expectCopyLinkUnavailable(
|
|
page.locator(`[data-message-id="${pendingId}"]`),
|
|
pendingId,
|
|
);
|
|
await expectCopyLinkUnavailable(
|
|
page.locator(`[data-message-id="${huddleId}"]`),
|
|
huddleId,
|
|
);
|
|
});
|