test(desktop): settle five E2E flake classes and clear a draft by Backspace (#8744)

## Summary

Units 6–9 and 14 of the 23–30 Oct desktop Playwright flake audit, in two
commits. The specs now settle before they measure or act, and
remote-owned-mentions clears its draft reliably. Everything is
test-only. This is one of six PRs split from the reviewed `flakey-tests`
branch (siblings below).

### Unit 6: measuring before an animation finishes

Each spec now calls `waitForAnimations(page)` before measuring.

- `onboarding.spec.ts:1487` (#8715, Integration shard 1; the centring
check at :1545 receives 5.13): `showPage("owned")` slides content in
from `translateX(48px)` over 650 ms. The spec reads its three boxes in
separate `boundingBox` round trips, so they can come from different
points of the slide.
- `onboarding.spec.ts:2289` (main 40746677244 and 40925172215, and
#8666; the spacing check at :2485 receives 4.255): the avatar dialog has
a 200 ms zoom-in and a 250 ms height transition.
- `projects-v3-screenshots.spec.ts:280`, at :893 (main 39643531998,
40759209995 and 40969909906, plus four runs with fractional menu-edge
values): the context rail narrows from 380 to 280 px over 200 ms.

### Unit 7: `fill()` racing a caret move the app makes a frame later

- `mention-recipients.spec.ts:376` and `:97` (main 40969965981 and
40969858676): edit mode focuses the end of the composer in a
`requestAnimationFrame`. If that frame lands mid-`fill()`, the edit is
appended instead of replacing the text. **Change:** use #8682's
`replaceComposerText` at both call sites.
- `workflow-local-controls.spec.ts:151` (main 40926393897 and
40957616146, and #8239): `selectVariable` restores the caret in a
`requestAnimationFrame` after the spec's poll has passed. **Change:**
wait two animation frames before filling.

### Unit 8: clicks that hit the wrong element

- `profile.spec.ts:1128`, at :1543 (#8523): stacked Sonner success
toasts intercept the pointer. **Change:** a local `pressActionRow`
helper presses the five action rows with `toBeEnabled`, focus and Enter.
- `config-bridge-screenshots.spec.ts:314` (main 40967147375, and #8235):
`.last()` resolved while avatar fallbacks were still filling in on 200
ms timers, so it opened nadia's panel instead of Charlie's. **Change:**
pick Charlie's row by its text, and check the panel's name before
opening Runtime.

### Unit 9: the `createWorkflow` copies (preventive)

- `navigation.spec.ts:153` (7 failures on main, none since #8681; 14
first-attempt failures in Smoke shard 7): an unscoped
`getByLabel("Message text")` can fill the trigger's filter while
`AnimatePresence` keeps it mounted. **Change:** wait for the step
inspector, then fill inside it, as `workflows.spec` already does. The
wait now lives in one `addSendMessageStep` helper in
`tests/helpers/workflows.ts`, which `navigation.spec.ts` and
`workflows.spec.ts` (`createWorkflow`, :716, :820) all call.
- `workflow-reaction-picker.spec.ts:203-204` is left out. Its Reaction
Added trigger has no control labelled "Message text".

### Unit 14 (second commit): `remote-owned-mentions.spec.ts:566`

- **Run:** main 40998438103 (Smoke shard 9, job 123877347301). It passed
on retry and failed 1 of the 6 attempts it got in the window.
`sourceRecord()` at :673 returned thread A's draft instead of null.
- **Cause:** on ProseMirror, `fill("")` selects through the DOM, then
presses Delete. If ProseMirror hasn't seen the selection yet, it blocks
the Delete. Navigating to B then saves A's live text as its draft. This
is not a product bug: had the editor really been empty, the cleanup
would have cleared A's draft.
- **Change:** delete the text with one Backspace per character from the
end, then wait for the composer to read empty. The guard assertions are
unchanged.

### Related issue

None found. Sibling #8743 also edits `navigation.spec.ts`, in a separate
hunk. The two merge cleanly in either order. No open PR touches the
tests changed here. #6134, #8187, #5778, #8622, #7452, #7812, #8244 and
#3342 edit other tests in the same files.

### Testing

Not run locally, because Playwright bogs this machine down. Flake Check
will repeat the touched specs. `pnpm typecheck` and `pnpm check` pass,
and biome's 3 warnings are in untouched files. Ad hoc `tsc` runs over
the touched specs and the helper found the same pre-existing errors as
on their `main` copies.

Pushed with `git push --no-verify`, because the pre-push suites fail
when the laptop is under load.

### Readiness

An agent review of the combined `flakey-tests` branch (b432453af) found
no blockers. `buzz-review-completed` is not added, because the human
test step (AGENTS.md "Before opening a PR", step 3) hasn't happened yet.

### Siblings

- #8739 ci: Hermit fetch deadline and sccache fallback
- #8740 test(relay): thread_window aux-page hold and `api::media`
Postgres tests
- #8741 test(mobile): RunnerTests title fade and alert wait
- #8742 fix(desktop): deep-linked message hold (product change)
- #8743 test(desktop): four specs racing scroll settles and focus

🤖 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>
This commit is contained in:
Matt Toohey
2026-10-31 11:47:20 +11:00
committed by GitHub
parent 547de61aa5
commit fc0bb778b0
10 changed files with 82 additions and 37 deletions

View File

@@ -331,17 +331,21 @@ test.describe("config bridge screenshots", () => {
await page.getByTestId("channel-agents").click();
await expect(page.getByTestId("chat-title")).toHaveText("agents");
// Click an agent avatar in a full message row to open the profile side panel.
// Click Charlie's avatar to open the profile side panel. Pick the row by
// its text: each avatar fallback mounts on its own 200 ms timer after its
// row, so "the last row with an avatar" can still be nadia's.
await page
.getByTestId("message-row")
.filter({ has: page.locator('[data-testid^="message-avatar-"]') })
.last()
.filter({ hasText: "Charlie reporting in." })
.getByRole("button")
.first()
.click();
const panel = page.getByTestId("user-profile-panel");
await expect(panel).toBeVisible({ timeout: 10_000 });
await expect(panel.getByTestId("user-profile-name-row")).toContainText(
/charlie/i,
);
// Editable configuration lives inside the Runtime tab.
await panel.getByRole("tab", { name: "Runtime" }).click();

View File

@@ -112,7 +112,7 @@ test("ambiguous added mention blocks editing before clearing the draft", async (
await page.getByRole("menuitem", { name: "Edit message" }).click();
await expect(page.getByTestId("edit-target")).toBeVisible();
await expect(input).toHaveText("original message for ambiguity edit");
await input.fill("edited @Scout hello");
await replaceComposerText(input, "edited @Scout hello");
await page.getByTestId("send-message").click();
await expect(page.getByText(AMBIGUOUS, { exact: false })).toBeVisible();
await expect(input).toHaveText("edited @Scout hello");
@@ -400,7 +400,7 @@ test("editing to a longer typed member drops the original shorter reference", as
await row.getByRole("button", { name: "More actions" }).click();
await page.getByRole("menuitem", { name: "Edit message" }).click();
await expect(input).toHaveText("@Scout hello");
await input.fill("@Scout Jones hello");
await replaceComposerText(input, "@Scout Jones hello");
await page.getByTestId("send-message").click();
await expect
.poll(() =>

View File

@@ -2,6 +2,7 @@ import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { openSettings } from "../helpers/settings";
import { addSendMessageStep } from "../helpers/workflows";
const ENGINEERING_CHANNEL_ID = "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9";
const WATERCOLOR_CHANNEL_ID = "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11";
@@ -58,9 +59,7 @@ async function createWorkflow(
await dialog.getByRole("textbox", { name: "Workflow name" }).fill(name);
await dialog.getByRole("button", { name: "Save workflow name" }).click();
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
await page.getByRole("menuitem", { name: "Send Message" }).click();
await dialog.getByLabel("Message text").fill("Workflow notification");
await addSendMessageStep(dialog, "Workflow notification");
await dialog.getByRole("button", { name: "Create" }).click();
const activationConfirmation = page.getByRole("alertdialog", {
name: "This workflow may run often",

View File

@@ -2,6 +2,7 @@ import { hexToBytes } from "@noble/hashes/utils.js";
import { expect, test, type Page } from "@playwright/test";
import { nsecEncode, npubEncode } from "nostr-tools/nip19";
import { waitForAnimations } from "../helpers/animations";
import {
installMockBridge,
openChannelBrowser,
@@ -1521,6 +1522,9 @@ test("hosted community address line stays within the card for a long name", asyn
const longName = "a".repeat(63);
await communityNameInput.fill(longName);
await expect(communityNameInput).toHaveValue(longName);
// The page slides in for 650 ms after the sign-in dialog closes, and the
// three boxes below are read in separate round trips. Measure at rest.
await waitForAnimations(page);
const [surfaceBox, inputBox, suffixBox] = await Promise.all([
createSurface.boundingBox(),
@@ -2413,6 +2417,9 @@ test("connected first-community profile keeps navigation inside the card and bal
await avatarButton.click();
const avatarDialog = page.getByRole("dialog", { name: "Edit your avatar" });
await expect(avatarDialog).toBeVisible();
// The dialog zooms in from 95% and animates its height; the layout checks
// below read boxes in separate round trips, so take them at rest.
await waitForAnimations(page);
await expect(avatarDialog).toHaveAttribute(
"data-system-color-scheme",
"light",

View File

@@ -1,4 +1,4 @@
import { expect, test, type Page } from "@playwright/test";
import { expect, test, type Locator, type Page } from "@playwright/test";
import { npubEncode } from "nostr-tools/nip19";
import {
@@ -1525,27 +1525,35 @@ test("renders agent profile ingress subviews from the Playwright mock bridge", a
0,
);
}
await page.getByTestId("user-profile-duplicate-agent-row").click();
// The Stop and Start success toasts can stack over these rows, and a
// pointer over them pauses their dismissal. Press the rows with the
// keyboard, which the toasts cannot intercept.
const pressActionRow = async (row: Locator) => {
await expect(row).toBeEnabled();
await row.focus();
await row.press("Enter");
};
await pressActionRow(page.getByTestId("user-profile-duplicate-agent-row"));
const duplicateDialog = page.getByTestId("persona-dialog");
await expect(duplicateDialog).toBeVisible();
await duplicateDialog.getByRole("button", { name: "Cancel" }).click();
await page.getByTestId("user-profile-export-agent-row").click();
await pressActionRow(page.getByTestId("user-profile-export-agent-row"));
const exportDialog = page.getByTestId("agent-snapshot-export-dialog");
await expect(exportDialog).toBeVisible();
await exportDialog.getByRole("button", { name: "Cancel" }).click();
await page.getByTestId("user-profile-create-card-row").click();
await pressActionRow(page.getByTestId("user-profile-create-card-row"));
const cardMintDialog = page.getByTestId("agent-card-mint-dialog");
await expect(cardMintDialog).toBeVisible();
await page.keyboard.press("Escape");
await expect(cardMintDialog).toHaveCount(0);
const archiveAgentRow = page.getByTestId("user-profile-archive-agent-row");
await expect(archiveAgentRow).toHaveText(/Archive agent/);
await archiveAgentRow.click();
await pressActionRow(archiveAgentRow);
await expect(page.getByTestId("archive-confirm-dialog")).toBeVisible();
await page.getByRole("button", { name: "Cancel" }).click();
const deleteAgentRow = page.getByTestId("user-profile-delete-agent-row");
await expect(deleteAgentRow).toHaveText("Delete agent");
await deleteAgentRow.click();
await pressActionRow(deleteAgentRow);
await expect(page.getByTestId("agent-delete-confirm-dialog")).toBeVisible();
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByTestId("user-profile-tab-memories")).toHaveText(

View File

@@ -890,6 +890,9 @@ test("projects v3 workspace screenshot states", async ({ page }) => {
).toBeVisible();
await repositoryPanelTab.click();
await expect(repositoryActionsPanel).toBeVisible();
// The context rail animates from the chat width to the narrower repository
// width, and the workspace widens with it. Measure once it stops.
await waitForAnimations(page);
await expectInsetSection();
await page.getByRole("tab", { name: "Commits" }).click();
await expect(repositoryActionsPanel).toBeVisible();

View File

@@ -646,8 +646,16 @@ for (const incoming of ["B preserved"]) {
await expect(input).toHaveText(incoming);
await navigate(roots[0]);
await expect(input).toHaveAttribute("data-lifecycle-host", "retained");
await input.fill("new authored text");
await input.fill("");
const authored = "new authored text";
await input.fill(authored);
await expect(input).toHaveText(authored);
// fill("") can leave ProseMirror's text in place (see clearComposer), and
// leaving A would then save it as A's draft. Backspace from the caret at
// the end needs no DOM-selection hand-off.
for (let i = 0; i < authored.length; i += 1) {
await input.press("Backspace");
}
await expect(input).toHaveText("");
const sourceRecord = () =>
page.evaluate(([root, otherRoot]) => {
const key = Object.keys(localStorage).find((key) =>

View File

@@ -185,6 +185,16 @@ test("inserts template variables with keyboard control and restores the caret",
),
)
.toBe("Hello {{trigger.text}}".length);
// The browser already put the caret at the end, so the poll can pass before
// the insert's queued frame restores it. Let that frame run first: landing
// between fill()'s select-all and its typing, it would collapse the
// selection and append.
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
);
await textarea.fill("Keep {{auth");
await expect(listbox).toBeVisible();

View File

@@ -3,6 +3,7 @@ import { parse as parseYaml } from "yaml";
import { installMockBridge } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
import { addSendMessageStep } from "../helpers/workflows";
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
@@ -100,21 +101,7 @@ async function createWorkflow(
.click();
}
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
await page.getByRole("menuitem", { name: "Send Message" }).click();
// The inspector swaps from the trigger to the new step after the click; an
// unscoped fill can land in the trigger's message-text condition instead.
// The "Action" header switches at once, but the trigger body stays mounted
// through its exit animation (AnimatePresence mode="wait"), so also wait
// for a control only the step body renders.
const stepInspector = dialog.getByTestId("workflow-node-inspector");
await expect(
stepInspector.getByRole("button", { name: "Action", exact: true }),
).toBeVisible();
await expect(
stepInspector.getByRole("button", { name: "Step details" }),
).toBeVisible();
await stepInspector.getByLabel("Message text").fill("Workflow notification");
await addSendMessageStep(dialog, "Workflow notification");
if (options?.stepName) {
await dialog.getByRole("button", { name: "Step details" }).click();
await dialog.getByLabel("Name (optional)").fill(options.stepName);
@@ -721,9 +708,7 @@ test("captures the built editor at desktop and narrow widths", async ({
const dialog = page.getByRole("dialog", { name: "Create workflow" });
await selectWorkflowChannel(page, dialog);
await editWorkflowName(dialog, "editor_screenshot");
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
await page.getByRole("menuitem", { name: "Send Message" }).click();
await dialog.getByLabel("Message text").fill("Notify the workflow channel");
await addSendMessageStep(dialog, "Notify the workflow channel");
const inspector = dialog.getByTestId("workflow-node-inspector");
for (const viewport of [
@@ -826,9 +811,7 @@ test("pane routes use stable IDs and Form/YAML changes stay synchronized", async
await selectWorkflowChannel(page, dialog);
await editWorkflowName(dialog, "pane_sync_test");
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
await page.getByRole("menuitem", { name: "Send Message" }).click();
await dialog.getByLabel("Message text").fill("first message");
await addSendMessageStep(dialog, "first message");
await expect(page).toHaveURL(/pane=step%3Astep_1/);
await dialog.getByRole("button", { name: "Add after Step 1" }).click();

View File

@@ -0,0 +1,23 @@
import { expect, type Locator } from "@playwright/test";
/**
* Adds a Send Message step to an open workflow dialog and fills its text.
*
* The inspector swaps from the trigger to the new step after the click; an
* unscoped fill can land in the trigger's message-text condition instead.
* The "Action" header switches at once, but the trigger body stays mounted
* through its exit animation (AnimatePresence mode="wait"), so also wait
* for a control only the step body renders.
*/
export async function addSendMessageStep(dialog: Locator, text: string) {
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
await dialog.page().getByRole("menuitem", { name: "Send Message" }).click();
const stepInspector = dialog.getByTestId("workflow-node-inspector");
await expect(
stepInspector.getByRole("button", { name: "Action", exact: true }),
).toBeVisible();
await expect(
stepInspector.getByRole("button", { name: "Step details" }),
).toBeVisible();
await stepInspector.getByLabel("Message text").fill(text);
}