// Keyless browser regression for durable per-message feedback. Cold-seeds a // settled two-turn transcript (zero model calls), likes one assistant message // and sees the acknowledgement, replaces the Like through the Dislike dialog // with a category and a note, proves the judgment survives a full page reload // from the Host's canonical log, then retracts it. import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { SessionId } from '@deepseek-ai/dsh-session' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { acknowledgeReloadConnectionLoss, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { newEnglishPage, saveFailureShot } from './support.ts' // Borrowed read-only: this scenario needs any settled assistant message to // address, not a new recording (message-actions / sidebar-scrollbar pattern). const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) const MODE = webSnapshotMode() const SEED_ID = 'message-feedback-web-e2e' const NOTE = 'Read both files before answering.' describe('web e2e: durable per-message feedback', () => { let scaffold: WebScaffold let browser: Browser let page: Page let tripwire: ReturnType beforeAll(async () => { scaffold = await launchWebScaffold({}) await seedSession(scaffold, await readFile(SEED, 'utf8'), SEED_ID) browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) afterAll(async () => { await browser?.close() await scaffold?.close() }) /** * Open the seeded transcript. The first treeitem is the collapsible group * row; the session itself is the row beneath it. The group is already * expanded on a fresh load, so clicking it unconditionally would collapse it * and hide the session row. */ async function openSeededSession(): Promise { const groupRow = page.locator('[role="treeitem"]').first() await groupRow.waitFor({ timeout: 15_000 }) if (await groupRow.getAttribute('aria-expanded') !== 'true') await groupRow.click() const sessionRow = page.locator('[role="treeitem"]').nth(1) await sessionRow.waitFor({ timeout: 15_000 }) await sessionRow.click() } it.skipIf(MODE === 'record')('persists a Dislike with its category and note across a reload, then retracts', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-feedback')) await openSeededSession() // The controls live in the assistant message's IconActions row, which the // transcript reveals on hover/focus like copy and branch. Wait for the // settled closing text first: the strip mounts with that turn's tail. await page.getByText('DONE', { exact: true }).waitFor({ timeout: 30_000 }) const like = page.getByRole('button', { name: 'Good response' }).first() await like.waitFor({ timeout: 30_000 }) await like.scrollIntoViewIfNeeded() await like.hover() await like.click() // A Like records at once and is acknowledged; a recorded rating relabels // the button to what the next click would do. await page.getByRole('alert').filter({ hasText: 'Thanks for your feedback' }).waitFor({ timeout: 10_000 }) const rated = page.getByRole('button', { name: 'Remove rating' }).first() await expect.poll(() => rated.getAttribute('aria-pressed'), { timeout: 10_000 }).toBe('true') // Dislike opens the Session's feedback dialog; its submission replaces // the Like with a negative judgment carrying the category and note. await page.getByRole('button', { name: 'Bad response' }).first().click() const dialog = page.getByRole('dialog', { name: 'Submit feedback' }) await dialog.waitFor({ timeout: 10_000 }) await expect.poll(() => dialog.getByRole('textbox', { name: 'Feedback details' }).getAttribute('placeholder')) .toBe('Add details to help us improve. Your submission will include the current conversation log.') await dialog.getByRole('button', { name: 'Task result', exact: true }).click() await dialog.getByRole('textbox', { name: 'Feedback details' }).fill(NOTE) await dialog.getByRole('button', { name: 'Submit', exact: true }).click() await expect.poll(() => dialog.count(), { timeout: 10_000 }).toBe(0) await expect.poll(() => rated.getAttribute('aria-label'), { timeout: 10_000 }).toBe('Remove rating') await expect.poll(() => like.getAttribute('aria-pressed'), { timeout: 10_000 }).toBe('false') // The durable assertion: a cold browser re-reads the sidecar over the wire. const warningStart = tripwire.warnings.length await page.reload({ waitUntil: 'load' }) acknowledgeReloadConnectionLoss(tripwire, warningStart) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await openSeededSession() await page.getByText('DONE', { exact: true }).waitFor({ timeout: 30_000 }) // The controller defers its list read to the first hover or focus, so a // cold reload shows the unrated label until the strip is touched. Hovering // the unrated control is what triggers the authoritative re-read. const cold = page.getByRole('button', { name: 'Good response' }).first() await cold.waitFor({ timeout: 30_000 }) await cold.scrollIntoViewIfNeeded() await cold.hover() const restored = page.getByRole('button', { name: 'Remove rating' }).first() await restored.waitFor({ timeout: 30_000 }) await restored.scrollIntoViewIfNeeded() await restored.hover() await expect.poll(() => restored.getAttribute('aria-pressed'), { timeout: 15_000 }).toBe('true') // The retract label sits on the Dislike side: the Like stays unpressed. await expect.poll(() => cold.getAttribute('aria-pressed'), { timeout: 10_000 }).toBe('false') const agent = scaffold.ctx.agents.get(SessionId(SEED_ID)) if (agent === undefined) throw new Error('seeded session did not attach an agent') const put = agent.session.snapshotEvents().filter(event => event.type === 'feedback/message-put').at(-1) expect(put?.type === 'feedback/message-put' ? put.data.item : undefined) .toMatchObject({ rating: 'negative', note: NOTE, category: 'task-result' }) // Re-clicking the active rating retracts it, and the note goes with it. await restored.click() await expect.poll( () => page.getByRole('button', { name: 'Bad response' }).first().getAttribute('aria-pressed'), { timeout: 10_000 }, ).toBe('false') const last = agent.session.snapshotEvents().at(-1) expect(last?.type).toBe('feedback/message-delete') }, 90_000) it.skipIf(MODE === 'record')('kept the console clean', () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }) })