fix(e2e): unambiguous chat conversation + new-channel locators — rc1-day2 root cause #1

22 @critical failures in 41-chat-deep.spec.ts shared one root cause:
`firstConversationRow` searched for `button[type="button"]` inside
the sidebar container, which also matched the "New Channel" CTA
button at the sidebar footer. When the listener test user had no
conversations seeded, `waitForConversationOrEmpty` raced and
returned 'has-conversations' because the CTA button matched the
conversation-row locator — `selectFirstConversation` then clicked
the CTA, opened CreateRoomDialog, and the subsequent
`expect(input).toBeEnabled()` failed because clicking the CTA
never set `currentConversationId`.

Fix:
  * `data-testid="chat-conversation-item"` on ConversationItem
    (+ `data-conversation-id` for callers that need the id).
  * `data-testid="chat-new-channel-cta"` on the New Channel
    footer button.
  * `firstConversationRow` / `waitForConversationOrEmpty` /
    `createRoom` rewired to target by testid. No more overlap.
  * Shared helper `tests/e2e/helpers/conversation.ts` with a
    minimal `navigateToConversation(page)` — picks the first
    existing conversation if any, else creates a disposable one,
    returns when the message input is enabled. Signature is
    deliberately minimal (no options) to avoid the second-API-
    surface trap. Future callers that need specialised behavior
    set up store state directly instead of extending this helper.

Results:
  * 22 failed → 20 passed / 3 failed / 10 skipped (graceful skips
    when test user lacks seed data).
  * The 3 remaining failures are distinct root causes:
    - `:220` chat page debug text leak (suspected [object Object]
      or undefined rendering somewhere in chat UI — real bug,
      tracked separately)
    - `:339` / `:347` createRoom DOM-detach race: the "Create
      room" button gets detached mid-click, suggesting the dialog
      is re-rendering during the click handler. Likely a fix in
      the dialog lifecycle rather than the test. Tracked
      separately.

29-chat-functional.spec.ts (2 failures on send-message) not
touched by this fix — those tests don't hit the row-vs-CTA
ambiguity, they fail further downstream when the backend doesn't
echo sent messages. Same class as #7 (backend-side chat
processing incomplete in test env).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
senke 2026-04-18 17:11:57 +02:00
parent 5349b80052
commit 7c74a6d408
4 changed files with 88 additions and 17 deletions

View file

@ -93,6 +93,7 @@ export const ChatSidebar: React.FC = () => {
onClick={() => setIsCreateDialogOpen(true)}
className="w-full shadow-lg shadow-sm"
variant="default"
data-testid="chat-new-channel-cta"
>
<Plus className="mr-2 h-4 w-4" />
New Channel

View file

@ -106,6 +106,8 @@ function ConversationItemInner({
type="button"
tabIndex={0}
onClick={() => onSelect(conversation.id)}
data-testid="chat-conversation-item"
data-conversation-id={conversation.id}
className={cn(
'appearance-none bg-transparent border-0 p-0 text-left w-full',
'group relative flex items-center justify-between p-4 rounded-xl cursor-pointer transition-all duration-[var(--sumi-duration-normal)] border border-transparent',

View file

@ -51,16 +51,18 @@ async function waitForChatPageReady(page: Page): Promise<void> {
).toBeVisible({ timeout: CONFIG.timeouts.navigation });
}
/** Get the first conversation row from the sidebar (buttons rendered by ConversationItem). */
/** Get the first conversation row from the sidebar (buttons rendered by ConversationItem).
*
* v1.0.7-rc1-day2: targeted by data-testid="chat-conversation-item"
* (added on ConversationItem). The prior locator
* `button[type="button"]` inside the sidebar also matched the
* "New Channel" CTA button at the sidebar footer producing the
* 22-failure cascade where `selectFirstConversation` would click
* the CTA, open CreateRoomDialog, and the test would fail at the
* subsequent `expect(input).toBeEnabled()` because the click never
* set currentConversationId. */
function firstConversationRow(page: Page) {
// ConversationItem renders a <button> whose left side contains the channel name
// inside an h2 "Active Channels" sidebar. We narrow to that sidebar region.
return page
.locator('div.flex.flex-col.h-full')
.filter({ has: page.locator('h2:has-text("Active Channels")') })
.locator('button[type="button"]')
.filter({ hasText: /.+/ })
.first();
return page.getByTestId('chat-conversation-item').first();
}
/** Get the connection status indicator (dot next to "CHANNELS" header). */
@ -89,13 +91,11 @@ async function waitForConversationOrEmpty(
page: Page,
timeout = 8_000,
): Promise<'has-conversations' | 'empty' | 'unknown'> {
const sidebar = page
.locator('div.flex.flex-col.h-full')
.filter({ has: page.locator('h2:has-text("Active Channels")') });
// Race between the first conversation row and the empty-state banner.
const firstRow = sidebar.locator('button[type="button"]').first();
const emptyBanner = sidebar.locator('text=/No conversations yet/i').first();
// v1.0.7-rc1-day2: conversation rows targeted by testid instead of
// the sidebar-region + button[type="button"] heuristic, which
// matched the "New Channel" CTA alongside real conversations.
const firstRow = page.getByTestId('chat-conversation-item').first();
const emptyBanner = page.locator('text=/No conversations yet/i').first();
try {
await Promise.race([
@ -128,7 +128,9 @@ async function selectFirstConversation(page: Page): Promise<boolean> {
/** Create a brand-new channel via the sidebar dialog. Returns the name. */
async function createRoom(page: Page): Promise<string> {
const name = `e2e-deep-${Date.now()}`;
await page.getByRole('button', { name: /new channel/i }).click();
// v1.0.7-rc1-day2: target the sidebar CTA by testid (prior regex
// was brittle and collided with the conversation-row locator).
await page.getByTestId('chat-new-channel-cta').click();
const nameInput = page.locator('#room-name');
await expect(nameInput).toBeVisible({ timeout: CONFIG.timeouts.action });

View file

@ -0,0 +1,66 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
/**
* Navigate to a chat conversation ready for message input.
*
* Minimal signature by design a helper that starts growing options
* becomes a second API surface. If a test needs a specific conversation
* id, a specific room type, or pre-seeded messages, that test should
* fetch via the API + set store state directly rather than extending
* this helper. Keep it boring.
*
* Flow:
* 1. /chat must already be loaded and the sidebar rendered.
* 2. If at least one conversation row exists: click the first one.
* 3. Otherwise: click "New Channel", create a disposable room.
* 4. Wait for the message input to become enabled that's the
* observable signal that `currentConversationId` is set in the
* chat store (ChatInput.tsx disables on `!currentConversationId`).
*
* Returns the conversation id chosen (via data-conversation-id attr)
* when an existing one is selected, or null when a new one was
* created (the id is assigned by the backend after dialog submit).
*
* Target selectors use testids added in v1.0.7-rc1-day2:
* - chat-conversation-item (on ConversationItem button)
* - chat-new-channel-cta (on the "New Channel" footer button)
* The pre-rc1 tests used `button[type="button"]` which ambiguously
* matched the "New Channel" CTA itself, producing the 22-failure
* cascade.
*/
export async function navigateToConversation(
page: Page,
): Promise<string | null> {
const existing = page.getByTestId('chat-conversation-item').first();
const existingCount = await page.getByTestId('chat-conversation-item').count();
if (existingCount > 0) {
const id = await existing.getAttribute('data-conversation-id');
await existing.click();
await expectInputReady(page);
return id;
}
// No conversation exists — create a disposable one. Room name is
// timestamped to avoid collisions across concurrent test workers.
const roomName = `e2e-helper-${Date.now()}`;
await page.getByTestId('chat-new-channel-cta').click();
const nameInput = page.locator('#room-name');
await expect(nameInput).toBeVisible({ timeout: 5_000 });
await nameInput.fill(roomName);
await page.getByRole('button', { name: /^create room$/i }).click();
// Dialog closes, new conversation becomes current.
await expect(nameInput).toBeHidden({ timeout: 5_000 });
await expectInputReady(page);
return null;
}
async function expectInputReady(page: Page): Promise<void> {
const input = page.getByLabel('Type a message');
await expect(input).toBeVisible({ timeout: 8_000 });
await expect(input).toBeEnabled({ timeout: 8_000 });
}