test(desktop): scope e2e transcript helpers to the active chat surface

The sidebar "+" now stacks a tab instead of replacing the surface, so the
prior session stays mounted and several chat surfaces can be on the page at
once. Helpers that waited for the old transcript to disappear from the page
timed out, and `.first()` locators / bare `document.querySelector` calls
started resolving against the wrong session (CI's "resolved to 2 elements"
strict-mode violation).

Target the most recently mounted `[data-composer-target]` surface instead,
and assert the NEW surface is empty rather than waiting for the old text to
vanish.
This commit is contained in:
Brooklyn Nicholson 2026-07-26 03:57:49 -05:00
parent bd86ce9938
commit fc39c7ac31
4 changed files with 113 additions and 44 deletions

View file

@ -21,8 +21,18 @@ const INFERENCE_SWITCH_TRIGGER = 'E2E_INFERENCE_SWITCH_TRIGGER'
const INFERENCE_PROMPT = `${INFERENCE_SWITCH_TRIGGER}: original inference prompt must remain singular.`
const INFERENCE_CORRECTION = `${INFERENCE_SWITCH_TRIGGER}: correction sent while inference is live.`
// A ⌘T-style tab can put several chat surfaces on the page at once, so every
// locator here is scoped to the ACTIVE one (the most recently mounted surface)
// rather than `.first()` / a bare document query, which would silently target
// the wrong session's composer or transcript.
const SURFACE = '[data-composer-target]'
function activeSurface(page: Page) {
return page.locator(SURFACE).last()
}
async function send(page: Page, text: string): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
const composer = activeSurface(page).locator('[contenteditable="true"]').first()
await composer.waitFor({ state: 'visible', timeout: 15_000 })
await composer.click()
await composer.type(text, { delay: 5 })
@ -30,8 +40,9 @@ async function send(page: Page, text: string): Promise<void> {
}
async function steer(page: Page, text: string): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
const surface = activeSurface(page)
const composer = surface.locator('[contenteditable="true"]').first()
const primary = surface.locator('[data-slot="composer-root"] button[type="submit"]')
await composer.waitFor({ state: 'visible', timeout: 15_000 })
await composer.click()
@ -42,55 +53,78 @@ async function steer(page: Page, text: string): Promise<void> {
async function waitForTranscriptText(page: Page, text: string): Promise<void> {
await page.waitForFunction(
(expected: string) => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
text,
([expected, surfaceSelector]: [string, string]) => {
const surfaces = document.querySelectorAll(surfaceSelector)
const active = surfaces[surfaces.length - 1]
return (active?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected)
},
[text, SURFACE] as [string, string],
{ timeout: 30_000 },
)
}
async function textNodeOccurrences(page: Page, text: string): Promise<number> {
return page.evaluate((expected: string) => {
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
if (!viewport) return 0
return page.evaluate(
([expected, surfaceSelector]: [string, string]) => {
const surfaces = document.querySelectorAll(surfaceSelector)
const viewport = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')
if (!viewport) return 0
const walker = document.createTreeWalker(viewport, NodeFilter.SHOW_TEXT)
let count = 0
while (walker.nextNode()) {
if (walker.currentNode.textContent?.includes(expected)) {
count += 1
const walker = document.createTreeWalker(viewport, NodeFilter.SHOW_TEXT)
let count = 0
while (walker.nextNode()) {
if (walker.currentNode.textContent?.includes(expected)) {
count += 1
}
}
}
return count
}, text)
return count
},
[text, SURFACE] as [string, string],
)
}
async function transcriptTextOrder(page: Page): Promise<string[]> {
return page.evaluate(() => {
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
return page.evaluate((surfaceSelector: string) => {
const surfaces = document.querySelectorAll(surfaceSelector)
const viewport = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')
if (!viewport) return []
return Array.from(viewport.querySelectorAll<HTMLElement>('[data-role="message"], [data-message-id]'))
.map(message => message.textContent?.trim() ?? '')
.filter(Boolean)
})
}, SURFACE)
}
async function transcriptMessageOrder(page: Page): Promise<string[]> {
return page.evaluate(() => {
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
return page.evaluate((surfaceSelector: string) => {
const surfaces = document.querySelectorAll(surfaceSelector)
const viewport = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')
if (!viewport) return []
return Array.from(viewport.querySelectorAll<HTMLElement>('[data-role="user"], [data-role="assistant"]'))
.map(message => message.textContent?.trim() ?? '')
.filter(Boolean)
})
}, SURFACE)
}
/**
* The sidebar "+" opens a NEW TAB beside the current chat rather than
* replacing it, so the prior session stays mounted in its own surface. Wait
* for the newly-mounted surface to show an empty transcript instead of waiting
* for the old text to disappear from the page (it never will).
*/
async function openFreshDraft(page: Page, priorSessionText: string): Promise<void> {
await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click()
await page.waitForFunction(
(priorText: string) => !(document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(priorText),
priorSessionText,
([priorText, surfaceSelector]: [string, string]) => {
const surfaces = document.querySelectorAll(surfaceSelector)
const active = surfaces[surfaces.length - 1]
const transcript = active?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? ''
return surfaces.length > 0 && !transcript.includes(priorText)
},
[priorSessionText, SURFACE] as [string, string],
{ timeout: 15_000 },
)
}

View file

@ -93,28 +93,54 @@ function sessionRow(page: Page) {
return page.locator('[data-slot="sidebar"] button').filter({ hasText: CAPTION }).first()
}
// A ⌘T-style tab can put several chat surfaces on the page at once, so
// transcript queries target the ACTIVE (most recently mounted) surface rather
// than the first match, which would read the wrong session.
const SURFACE = '[data-composer-target]'
function activeViewportText(surfaceSelector: string): string {
const surfaces = document.querySelectorAll(surfaceSelector)
return surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? ''
}
async function openSeededSession(page: Page): Promise<void> {
const row = sessionRow(page)
await row.waitFor({ state: 'visible', timeout: 60_000 })
await row.click()
await page.waitForFunction(
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
CAPTION,
([expected, surfaceSelector]: [string, string]) => {
const surfaces = document.querySelectorAll(surfaceSelector)
const text = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? ''
return text.includes(expected)
},
[CAPTION, SURFACE] as [string, string],
{ timeout: 30_000 },
)
}
/**
* The sidebar "+" opens a NEW TAB beside the current chat instead of replacing
* it, so the seeded session stays mounted in its own surface. Assert the new
* surface is empty rather than waiting for the old caption to leave the page.
*/
async function openNewSession(page: Page): Promise<void> {
await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click()
await page.waitForFunction(
expected => !(document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
CAPTION,
([expected, surfaceSelector]: [string, string]) => {
const surfaces = document.querySelectorAll(surfaceSelector)
const text = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? ''
return surfaces.length > 0 && !text.includes(expected)
},
[CAPTION, SURFACE] as [string, string],
{ timeout: 15_000 },
)
}
async function transcriptText(page: Page): Promise<string> {
return page.evaluate(() => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '')
return page.evaluate(activeViewportText, SURFACE)
}
async function assertRendersThumbnail(page: Page, label: string): Promise<void> {

View file

@ -294,12 +294,15 @@ test('warm-route resume paints transcript exactly once (no jitter)', async ({},
.first()
await newSessionButton.click()
// Wait for the new-chat empty state.
// Wait for the new-chat empty state. The "+" opens a NEW TAB beside the
// resumed session rather than replacing it, so the old transcript stays
// mounted — assert the newly-added surface is the empty one.
await page.waitForFunction(
(firstMsg: string) => {
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
if (!viewport) return false
const text = viewport.textContent ?? ''
const surfaces = document.querySelectorAll('[data-composer-target]')
const active = surfaces[surfaces.length - 1]
if (!active) return false
const text = active.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? ''
return !text.includes(firstMsg)
},
FIRST_USER_MSG,
@ -392,11 +395,14 @@ test('warm-route resume after background inference completes (no jitter)', async
.locator('[data-slot="sidebar"] button[aria-label="New session"]')
.first()
await newSessionButton.click()
// "+" stacks a new tab, so the prior transcript stays mounted in its own
// surface — check the newly-added surface rather than the whole page.
await page.waitForFunction(
(prompt: string) => {
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
if (!viewport) return false
return !(viewport.textContent ?? '').includes(prompt)
const surfaces = document.querySelectorAll('[data-composer-target]')
const active = surfaces[surfaces.length - 1]
if (!active) return false
return !(active.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(prompt)
},
PROMPT,
{ timeout: 15_000 },

View file

@ -481,14 +481,17 @@ export function ContribWiring({ children }: { children: ReactNode }) {
void refreshActiveProfile()
}, [activeGatewayProfile, refreshCurrentModel, refreshHermesConfig])
// New session anchored to a workspace (sidebar "+" on a project/worktree).
// Seeds cwd + branch from the clicked workspace; an explicit worktree path
// also drills the sidebar into that project so the new lane is visible.
// Once a chat is loaded the button opens a TAB instead of replacing it —
// see newSessionOpensTab.
// New session anchored to a workspace. Seeds cwd + branch from the clicked
// workspace; an explicit worktree path also drills the sidebar into that
// project so the new lane is visible.
//
// `openTab` is the sidebar "+" behavior: once a chat is loaded, stack a new
// tab instead of replacing it (see newSessionOpensTab). The composer's
// "branch off into a new worktree" flow keeps the fresh-draft path — it
// prefills the MAIN composer right after, so it has to own that surface.
const startSessionInWorkspace = useCallback(
(path: null | string) => {
if (newSessionOpensTab(activeSessionIdRef.current, $selectedStoredSessionId.get())) {
(path: null | string, options?: { openTab?: boolean }) => {
if (options?.openTab && newSessionOpensTab(activeSessionIdRef.current, $selectedStoredSessionId.get())) {
void openNewSessionTile('center', { cwd: path, listed: false })
return
@ -799,7 +802,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
navigate(CRON_ROUTE)
},
onNavigate: selectSidebarItem,
onNewSessionInWorkspace: startSessionInWorkspace,
onNewSessionInWorkspace: path => startSessionInWorkspace(path, { openTab: true }),
onNewSessionSplit: dir => void openNewSessionTile(dir),
onPasteClipboardImage: opts => composer.pasteClipboardImage(opts),
onPickFiles: () => void composer.pickContextPaths('file'),