mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-28 18:19:28 +00:00
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:
parent
bd86ce9938
commit
fc39c7ac31
4 changed files with 113 additions and 44 deletions
|
|
@ -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 },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue