|
2 | 2 | * Resiliency spec: Double-submit idempotency |
3 | 3 | * |
4 | 4 | * Sending a message twice in rapid succession (two clicks / two Enter presses |
5 | | - * before the first response arrives) must produce exactly ONE assistant reply |
6 | | - * bubble — not two. Today, the frontend does not guard against this and a |
7 | | - * second identical run is kicked off, so the test is annotated test.fail(). |
| 5 | + * before the first response arrives) must produce exactly ONE assistant run — |
| 6 | + * not two. A short-window content dedup guard in useMessageQueue rejects the |
| 7 | + * duplicate submit so only a single run is kicked off. |
8 | 8 | * |
9 | | - * FLIP: remove test.fail() once the double-submit guard lands. |
| 9 | + * Oracle: each accepted submit renders exactly one user message bubble |
| 10 | + * (`div.rounded-br-sm`). Counting user bubbles measures the number of runs |
| 11 | + * directly and is independent of whether/how the assistant reply renders (a |
| 12 | + * single assistant turn emits several `rounded-bl-sm` part elements, so the |
| 13 | + * assistant-bubble count cannot distinguish one run from two). After a |
| 14 | + * deduped double-submit there must be exactly ONE user bubble. |
| 15 | + * |
| 16 | + * The chat list is virtualized (ChatMessages.tsx useVirtualizer): a long |
| 17 | + * streaming reply scrolls row 0 (the user bubble) out of the virtual window |
| 18 | + * and unmounts it, so the count must be taken with the list scrolled to the |
| 19 | + * top. `countUserBubbles` scrolls to top, lets the virtualizer re-mount, then |
| 20 | + * counts. |
10 | 21 | */ |
11 | 22 |
|
12 | | -import { test, expect } from "@playwright/test"; |
| 23 | +import { test, expect, Page } from "@playwright/test"; |
13 | 24 | import { loginAsAdmin } from "./helpers/auth"; |
14 | 25 |
|
15 | 26 | // Selector constants derived from verified live selectors in the briefing |
16 | 27 | const CHAT_INPUT = 'textarea[placeholder="How can I help you be more productive?"]'; |
17 | 28 | const SUBMIT_BUTTON = '[data-tour="chat-submit-button"]'; |
18 | | -// Assistant response bubble class confirmed in ChatMessages.tsx line ~194-204 |
19 | | -const ASSISTANT_BUBBLE = "div.rounded-bl-sm"; |
| 29 | +// User (human) message bubble class — one per accepted submit (ChatMessages.tsx:100) |
| 30 | +const USER_BUBBLE = "div.rounded-br-sm"; |
| 31 | + |
| 32 | +/** |
| 33 | + * Scroll the virtualized chat list to the top so row 0 (the user bubble) is |
| 34 | + * mounted, then return the number of user bubbles. Without scrolling to top a |
| 35 | + * long streaming reply can unmount row 0 and yield a false 0 count. |
| 36 | + */ |
| 37 | +async function countUserBubbles(page: Page): Promise<number> { |
| 38 | + await page.evaluate(() => { |
| 39 | + const scroller = document.querySelector("div.overflow-auto"); |
| 40 | + if (scroller) scroller.scrollTop = 0; |
| 41 | + }); |
| 42 | + // Give the virtualizer a frame to re-mount the top rows |
| 43 | + await page.waitForTimeout(300); |
| 44 | + return page.locator(USER_BUBBLE).count(); |
| 45 | +} |
20 | 46 |
|
21 | 47 | test.describe("Double-submit idempotency", () => { |
22 | 48 | test.beforeEach(async ({ page }) => { |
23 | 49 | await loginAsAdmin(page); |
24 | | - // Navigate to chat; adjust if the default route is different |
25 | | - await page.goto("/", { waitUntil: "networkidle" }); |
| 50 | + // Navigate straight to /chat (the authed default route) to avoid the |
| 51 | + // "/" -> "/chat" redirect race that can delay the input mount. Use |
| 52 | + // domcontentloaded (not networkidle) — the chat page holds a long-lived |
| 53 | + // streaming connection, so the network never goes idle and networkidle |
| 54 | + // would abort the navigation. |
| 55 | + await page.goto("/chat", { waitUntil: "domcontentloaded" }); |
| 56 | + await page.locator(CHAT_INPUT).waitFor({ state: "visible", timeout: 30_000 }); |
26 | 57 | }); |
27 | 58 |
|
28 | | - // FLIP: remove test.fail() once the double-submit guard lands. |
29 | | - test.fail( |
30 | | - true, |
31 | | - "Double-submit currently produces two assistant bubbles; expected one (guard not yet implemented)", |
32 | | - ); |
33 | | - |
34 | | - test("typing a message and submitting twice produces exactly one assistant bubble", async ({ |
| 59 | + test("typing a message and submitting twice produces exactly one user bubble", async ({ |
35 | 60 | page, |
36 | 61 | }) => { |
37 | 62 | // Type a short deterministic message |
38 | 63 | const input = page.locator(CHAT_INPUT); |
39 | 64 | await input.fill("ping idempotency test"); |
40 | 65 |
|
41 | | - // Submit twice as fast as possible |
| 66 | + // Submit twice as fast as possible (force-click to bypass actionability |
| 67 | + // waits and devtools overlay so the two clicks land in rapid succession). |
42 | 68 | const submitBtn = page.locator(SUBMIT_BUTTON); |
43 | | - await submitBtn.click(); |
44 | | - // Second click immediately — no await between them |
45 | | - await submitBtn.click(); |
| 69 | + await submitBtn.click({ force: true }); |
| 70 | + // Second click immediately — no await between dispatch and re-fire |
| 71 | + await submitBtn.click({ force: true }); |
46 | 72 |
|
47 | | - // Wait for at least one assistant bubble to appear (the backend will reply) |
48 | | - await expect(page.locator(ASSISTANT_BUBBLE).first()).toBeVisible({ |
| 73 | + // Wait for the first user bubble to render |
| 74 | + await expect(page.locator(USER_BUBBLE).first()).toBeVisible({ |
49 | 75 | timeout: 30_000, |
50 | 76 | }); |
51 | 77 |
|
52 | | - // Allow up to 5 s for a second bubble to materialise (it shouldn't) |
| 78 | + // Allow up to 5 s for a second run to materialise (it shouldn't) |
53 | 79 | await page.waitForTimeout(5_000); |
54 | 80 |
|
55 | | - // RESILIENT OUTCOME: exactly one assistant bubble |
56 | | - const bubbles = page.locator(ASSISTANT_BUBBLE); |
57 | | - await expect(bubbles).toHaveCount(1); |
| 81 | + // RESILIENT OUTCOME: exactly one user bubble (one accepted run) |
| 82 | + expect(await countUserBubbles(page)).toBe(1); |
58 | 83 | }); |
59 | 84 |
|
60 | | - test("pressing Enter twice rapidly produces exactly one assistant bubble", async ({ |
| 85 | + test("pressing Enter twice rapidly produces exactly one user bubble", async ({ |
61 | 86 | page, |
62 | | - }) => { |
| 87 | + }, testInfo) => { |
| 88 | + // Enter-to-submit is intentionally disabled on mobile (ChatInput.tsx:157 |
| 89 | + // gates handleEnqueue on `!isLikelyMobile()`), so the double-Enter path |
| 90 | + // does not exist on touch devices — there is nothing to dedup there. |
| 91 | + test.skip( |
| 92 | + testInfo.project.name === "mobile", |
| 93 | + "Enter-to-submit is disabled on mobile by design (ChatInput.tsx:157)", |
| 94 | + ); |
| 95 | + |
63 | 96 | const input = page.locator(CHAT_INPUT); |
64 | 97 | await input.fill("ping idempotency enter"); |
65 | 98 |
|
66 | 99 | // Two Enter presses with no gap |
67 | 100 | await input.press("Enter"); |
68 | 101 | await input.press("Enter"); |
69 | 102 |
|
70 | | - await expect(page.locator(ASSISTANT_BUBBLE).first()).toBeVisible({ |
| 103 | + await expect(page.locator(USER_BUBBLE).first()).toBeVisible({ |
71 | 104 | timeout: 30_000, |
72 | 105 | }); |
73 | 106 |
|
74 | 107 | await page.waitForTimeout(5_000); |
75 | 108 |
|
76 | | - const bubbles = page.locator(ASSISTANT_BUBBLE); |
77 | | - await expect(bubbles).toHaveCount(1); |
| 109 | + expect(await countUserBubbles(page)).toBe(1); |
78 | 110 | }); |
79 | 111 | }); |
0 commit comments