Files
buzz/desktop/tests/e2e/nostr-bind.spec.ts
Matt Toohey 0052b2354e test(desktop): gate flaky E2E specs on the state they assume (#8681)
## 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>
2026-10-30 09:59:40 +11:00

470 lines
14 KiB
TypeScript

import { expect, test, type Locator, type Page } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
type NostrBindPayload = {
action: string;
audience: string;
callbackUrl?: string;
challengeId: string;
expiresAt: string;
nonce: string;
origin: string;
protocol: string;
returnMode: string;
verificationCode: string;
version: string;
};
const VALID_REQUEST: NostrBindPayload = {
action: "bind_nostr_identity",
audience: "buzz:nostr-identity",
challengeId: "550e8400-e29b-41d4-a716-446655440000",
expiresAt: "2099-01-01T00:00:00Z",
nonce: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567",
origin: "https://admin.example.com",
protocol: "buzz-nostr-identity",
returnMode: "clipboard",
verificationCode: "123456",
version: "1",
};
async function emitNostrBind(page: Page, payload: NostrBindPayload) {
await page.evaluate(async (nextPayload) => {
const internals = (
window as Window & {
__TAURI_INTERNALS__?: {
invoke?: (
command: string,
args: Record<string, unknown>,
) => Promise<unknown>;
};
}
).__TAURI_INTERNALS__;
if (!internals?.invoke) {
throw new Error("Tauri E2E event bridge is unavailable");
}
await internals.invoke("plugin:event|emit", {
event: "deep-link-nostr-bind",
payload: nextPayload,
});
}, payload);
}
async function openNostrBind(
page: Page,
payload: NostrBindPayload = VALID_REQUEST,
mock?: Parameters<typeof installMockBridge>[1],
) {
await installMockBridge(page, mock);
await page.goto("/");
// The mocked emit drops events with no listener, and the app subscribes to
// deep links only after boot. Wait for that listener before emitting.
await page.waitForFunction(
() =>
(window.__BUZZ_E2E_EVENT_LISTENER_COUNT__?.("deep-link-nostr-bind") ??
0) > 0,
);
await emitNostrBind(page, payload);
await expect(page.getByTestId("nostr-bind-page")).toBeVisible();
}
async function pasteCode(input: Locator, code: string) {
await input.evaluate((element, pastedCode) => {
const clipboardData = new DataTransfer();
clipboardData.setData("text", pastedCode);
element.dispatchEvent(
new ClipboardEvent("paste", {
bubbles: true,
cancelable: true,
clipboardData,
}),
);
}, code);
}
async function signCommandPayloads(page: Page): Promise<unknown[]> {
return page.evaluate(() =>
(
(
window as Window & {
__BUZZ_E2E_COMMAND_LOG__?: Array<{
command: string;
payload: unknown;
}>;
}
).__BUZZ_E2E_COMMAND_LOG__ ?? []
)
.filter(({ command }) => command === "sign_nostr_identity_binding")
.map(({ payload }) => payload),
);
}
async function installClipboardStub(page: Page, shouldFail: boolean) {
await page.addInitScript(
({ fail }) => {
const testWindow = window as Window & {
__BUZZ_E2E_CLIPBOARD_TEXT__?: string;
};
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: {
writeText: async (text: string) => {
if (fail) {
throw new Error("clipboard unavailable");
}
testWindow.__BUZZ_E2E_CLIPBOARD_TEXT__ = text;
},
},
});
},
{ fail: shouldFail },
);
}
async function installShakeCounter(page: Page) {
await page.addInitScript(() => {
const testWindow = window as Window & {
__BUZZ_E2E_CODE_SHAKE_CALLS__?: number;
};
testWindow.__BUZZ_E2E_CODE_SHAKE_CALLS__ = 0;
const originalAnimate = Element.prototype.animate;
Element.prototype.animate = function animate(keyframes, options) {
if (
this instanceof HTMLElement &&
this.dataset.testid === "nostr-bind-verification-code"
) {
testWindow.__BUZZ_E2E_CODE_SHAKE_CALLS__ =
(testWindow.__BUZZ_E2E_CODE_SHAKE_CALLS__ ?? 0) + 1;
}
return originalAnimate.call(this, keyframes, options);
};
});
}
async function shakeCount(page: Page): Promise<number> {
return page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_CODE_SHAKE_CALLS__?: number;
}
).__BUZZ_E2E_CODE_SHAKE_CALLS__ ?? 0,
);
}
test("supports OTP entry and navigation without signing incomplete input", async ({
page,
}) => {
await openNostrBind(page);
await expect(page.getByText("Requesting origin")).toHaveCount(0);
await expect(page.getByText(VALID_REQUEST.origin)).toHaveCount(0);
const first = page.getByTestId("nostr-bind-code-digit-1");
const second = page.getByTestId("nostr-bind-code-digit-2");
const third = page.getByTestId("nostr-bind-code-digit-3");
const continueButton = page.getByTestId("nostr-bind-sign-and-copy");
await first.click();
await first.press("1");
await expect(second).toBeFocused();
await second.press("2");
await expect(third).toBeFocused();
await third.press("ArrowLeft");
await expect(second).toBeFocused();
await second.press("Backspace");
await expect(second).toHaveValue("");
await expect(continueButton).toBeDisabled();
await expect.poll(() => signCommandPayloads(page)).toEqual([]);
});
test("auto-signs exactly once when the sixth correct digit is typed", async ({
page,
}) => {
await openNostrBind(page);
await page.getByTestId("nostr-bind-code-digit-1").click();
for (const digit of VALID_REQUEST.verificationCode) {
await page.keyboard.type(digit);
}
await expect(page.getByTestId("nostr-bind-finish-step")).toBeVisible();
await expect.poll(() => signCommandPayloads(page)).toHaveLength(1);
await page.waitForTimeout(100);
await expect.poll(() => signCommandPayloads(page)).toHaveLength(1);
});
test("discards a signature when a newer pairing request arrives", async ({
page,
}) => {
await openNostrBind(page, VALID_REQUEST, { nostrBindSignDelayMs: 150 });
await pasteCode(
page.getByTestId("nostr-bind-code-digit-1"),
VALID_REQUEST.verificationCode,
);
await expect.poll(() => signCommandPayloads(page)).toHaveLength(1);
const nextRequest = {
...VALID_REQUEST,
challengeId: "550e8400-e29b-41d4-a716-446655440001",
verificationCode: "654321",
};
await emitNostrBind(page, nextRequest);
await expect(page.getByTestId("nostr-bind-code-digit-1")).toHaveValue("");
await page.waitForTimeout(200);
await expect(page.getByTestId("nostr-bind-code-step")).toBeVisible();
await expect(page.getByTestId("nostr-bind-finish-step")).toBeHidden();
await pasteCode(
page.getByTestId("nostr-bind-code-digit-1"),
nextRequest.verificationCode,
);
await expect(page.getByTestId("nostr-bind-finish-step")).toBeVisible();
await expect.poll(() => signCommandPayloads(page)).toHaveLength(2);
});
test("repeats mismatch feedback without signing", async ({ page }) => {
await installShakeCounter(page);
await openNostrBind(page);
const first = page.getByTestId("nostr-bind-code-digit-1");
const continueButton = page.getByTestId("nostr-bind-sign-and-copy");
await pasteCode(first, "654321");
await expect(page.getByRole("alert")).toHaveText(
"That code doesn't match. Check the code and try again.",
);
await expect(continueButton).toBeDisabled();
await expect.poll(() => shakeCount(page)).toBe(1);
await pasteCode(first, "654321");
await expect.poll(() => shakeCount(page)).toBe(2);
await expect.poll(() => signCommandPayloads(page)).toEqual([]);
});
test("honors reduced motion while rejecting a mismatched code", async ({
page,
}) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await installShakeCounter(page);
await openNostrBind(page);
await pasteCode(page.getByTestId("nostr-bind-code-digit-1"), "654321");
await expect(page.getByRole("alert")).toBeVisible();
await expect.poll(() => shakeCount(page)).toBe(0);
await expect.poll(() => signCommandPayloads(page)).toEqual([]);
});
test("rejects an expired request without signing", async ({ page }) => {
await openNostrBind(page, {
...VALID_REQUEST,
expiresAt: "2000-01-01T00:00:00Z",
});
await expect(
page.getByText(
"This binding link has expired. Request a new one from the requesting app.",
),
).toBeVisible();
await pasteCode(
page.getByTestId("nostr-bind-code-digit-1"),
VALID_REQUEST.verificationCode,
);
await expect(page.getByTestId("nostr-bind-sign-and-copy")).toBeDisabled();
await expect.poll(() => signCommandPayloads(page)).toEqual([]);
});
test("cancels a request without signing", async ({ page }) => {
await openNostrBind(page);
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByTestId("nostr-bind-page")).toBeHidden();
await expect.poll(() => signCommandPayloads(page)).toEqual([]);
});
test("signs a valid request, shows the response, and copies it", async ({
page,
}) => {
await installClipboardStub(page, false);
await openNostrBind(page);
await pasteCode(
page.getByTestId("nostr-bind-code-digit-1"),
VALID_REQUEST.verificationCode,
);
await expect(page.getByTestId("nostr-bind-finish-step")).toBeVisible();
const response = page.getByTestId("nostr-bind-signed-response");
await expect(response).toContainText("e2e-signed-nostr-binding");
await expect
.poll(() => signCommandPayloads(page))
.toEqual([
{
challengeId: VALID_REQUEST.challengeId,
expiresAt: VALID_REQUEST.expiresAt,
nonce: VALID_REQUEST.nonce,
origin: VALID_REQUEST.origin,
verificationCode: VALID_REQUEST.verificationCode,
},
]);
const signedResponse = await response.textContent();
await page.getByTestId("nostr-bind-copy-response").click();
await expect(
page.getByTestId("nostr-bind-copy-response"),
).toHaveAccessibleName("Copied");
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_CLIPBOARD_TEXT__?: string;
}
).__BUZZ_E2E_CLIPBOARD_TEXT__,
),
)
.toBe(signedResponse);
await expect.poll(() => signCommandPayloads(page)).toHaveLength(1);
await page.getByRole("button", { name: "Continue" }).click();
await expect(page.getByTestId("nostr-bind-page")).toBeHidden();
});
test("returns a signed response in the callback fragment after consent", async ({
page,
}) => {
await installClipboardStub(page, false);
await openNostrBind(page, {
...VALID_REQUEST,
callbackUrl: "https://admin.example.com/buzz?source=bind#stale",
returnMode: "browser_fragment_v1",
});
await expect
.poll(async () =>
page.evaluate(
() =>
(
(
window as Window & {
__BUZZ_E2E_COMMAND_LOG__?: Array<{ command: string }>;
}
).__BUZZ_E2E_COMMAND_LOG__ ?? []
).filter(({ command }) => command === "plugin:opener|open_url")
.length,
),
)
.toBe(0);
await pasteCode(
page.getByTestId("nostr-bind-code-digit-1"),
VALID_REQUEST.verificationCode,
);
await expect(page.getByTestId("nostr-bind-finish-step")).toBeVisible();
await expect(
page.getByRole("heading", { name: "Continue in your browser" }),
).toBeVisible();
const manualFallback = page.getByTestId("nostr-bind-manual-fallback");
await expect(manualFallback).not.toHaveAttribute("open", "");
await expect(page.getByTestId("nostr-bind-signed-response")).toBeHidden();
await expect(page.getByTestId("nostr-bind-copy-response")).toBeHidden();
const fallbackSummary = manualFallback.locator("summary");
await fallbackSummary.focus();
await fallbackSummary.press("Enter");
await expect(manualFallback).toHaveAttribute("open", "");
const signedResponse = page.getByTestId("nostr-bind-signed-response");
await expect(signedResponse).toContainText("e2e-signed-nostr-binding");
const copyResponse = page.getByTestId("nostr-bind-copy-response");
await expect(copyResponse).toBeVisible();
await copyResponse.click();
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_CLIPBOARD_TEXT__?: string;
}
).__BUZZ_E2E_CLIPBOARD_TEXT__,
),
)
.toBe(await signedResponse.textContent());
const callback = await page.evaluate(() => {
const command = (
(
window as Window & {
__BUZZ_E2E_COMMAND_LOG__?: Array<{
command: string;
payload: { url?: string };
}>;
}
).__BUZZ_E2E_COMMAND_LOG__ ?? []
).find(({ command }) => command === "plugin:opener|open_url");
return command?.payload.url;
});
const callbackUrl = new URL(callback ?? "");
expect(callbackUrl.origin).toBe("https://admin.example.com");
expect(callbackUrl.pathname).toBe("/buzz");
expect(callbackUrl.search).toBe("?source=bind");
expect(callbackUrl.searchParams.has("buzz_bind")).toBe(false);
expect(callbackUrl.hash).toMatch(/^#buzz_bind=v1\.[A-Za-z0-9_-]+$/);
});
test("opens the manual fallback when returning to the browser fails", async ({
page,
}) => {
await openNostrBind(
page,
{
...VALID_REQUEST,
callbackUrl: "https://admin.example.com/buzz",
returnMode: "browser_fragment_v1",
},
{ openerError: "browser unavailable" },
);
await pasteCode(
page.getByTestId("nostr-bind-code-digit-1"),
VALID_REQUEST.verificationCode,
);
await expect(
page.getByText(
"Could not open the browser. Copy the response below to finish manually.",
),
).toBeVisible();
await expect(page.getByTestId("nostr-bind-manual-fallback")).toHaveAttribute(
"open",
"",
);
await expect(page.getByTestId("nostr-bind-copy-response")).toBeVisible();
});
test("keeps the signed response available when clipboard access fails", async ({
page,
}) => {
await installClipboardStub(page, true);
await openNostrBind(page);
await pasteCode(
page.getByTestId("nostr-bind-code-digit-1"),
VALID_REQUEST.verificationCode,
);
await expect(page.getByTestId("nostr-bind-finish-step")).toBeVisible();
await page.getByTestId("nostr-bind-copy-response").click();
await expect(
page
.getByTestId("nostr-bind-manual-fallback-content")
.getByText("Buzz couldn't access the clipboard. Try again."),
).toBeVisible();
await expect(page.getByTestId("nostr-bind-signed-response")).toContainText(
"e2e-signed-nostr-binding",
);
await expect(
page.getByTestId("nostr-bind-copy-response"),
).toHaveAccessibleName("Copy response");
});