|
|
@@ -52,6 +52,11 @@ async function assertBaselineSucceeded(response: Response, method: string): Prom
|
|
|
}
|
|
|
|
|
|
async function ensureSeedOpen(page: Page): Promise<void> {
|
|
|
+ const welcome = page.locator('[class*="onboardingOverlay"]')
|
|
|
+ if (await welcome.count() > 0) {
|
|
|
+ await welcome.getByRole('button').click()
|
|
|
+ await welcome.waitFor({ state: 'detached', timeout: 15_000 })
|
|
|
+ }
|
|
|
const chat = page.getByRole('tab', { name: 'Chat', exact: true })
|
|
|
// Search is a collapsed header action; expand it so the input is actionable.
|
|
|
const searchButton = page.getByRole('button', { name: 'Search sessions' })
|
|
|
@@ -283,14 +288,30 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
|
|
await details.getByRole('button', { name: 'Close details' }).click()
|
|
|
}, 60_000)
|
|
|
|
|
|
- it.skipIf(MODE === 'record')('downloads the session-log ZIP from the trajectory toolbar', async () => {
|
|
|
+ it.skipIf(MODE === 'record')('downloads through the Session Header and /export with one dialog', async () => {
|
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-export'))
|
|
|
await ensureSeedOpen(page)
|
|
|
- await page.getByRole('tab', { name: 'Trajectory' }).click()
|
|
|
+ const exportButton = page.getByRole('button', { name: 'Session log' })
|
|
|
+ expect(await exportButton.isDisabled()).toBe(false)
|
|
|
+ const header = exportButton.locator('xpath=ancestor::header[1]')
|
|
|
+ const [buttonBox, headerBox] = await Promise.all([
|
|
|
+ exportButton.boundingBox(), header.boundingBox(),
|
|
|
+ ])
|
|
|
+ if (buttonBox === null || headerBox === null) {
|
|
|
+ throw new Error('Session Header export geometry is unavailable')
|
|
|
+ }
|
|
|
+ expect(headerBox.x + headerBox.width - (buttonBox.x + buttonBox.width)).toBeLessThanOrEqual(32)
|
|
|
+ const responsePromise = page.waitForResponse(response =>
|
|
|
+ response.request().method() === 'HEAD'
|
|
|
+ && new URL(response.url()).pathname === '/api/session.export', { timeout: 30_000 })
|
|
|
const downloadPromise = page.waitForEvent('download', { timeout: 30_000 })
|
|
|
- await page.getByRole('button', { name: 'Export session log' }).click()
|
|
|
+ await exportButton.click()
|
|
|
+ const response = await responsePromise
|
|
|
+ expect(response.status()).toBe(200)
|
|
|
const download = await downloadPromise
|
|
|
expect(download.suggestedFilename()).toMatch(/^dsh-session-.+\.zip$/)
|
|
|
+ const dialog = page.getByRole('dialog', { name: 'Session download started' })
|
|
|
+ await dialog.waitFor({ timeout: 30_000 })
|
|
|
// The real host streamed the ZIP; its root entry is the persisted log
|
|
|
// text verbatim (the assembled seam: real route, real persistence read).
|
|
|
const files = unzipSync(await readFile(await download.path()))
|
|
|
@@ -298,7 +319,63 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
|
|
const content = strFromU8(files['session.jsonl'] as Uint8Array)
|
|
|
expect(content.split('\n')[0]).toContain(SEED_ID)
|
|
|
expect(content).toContain('FIRST_DONE')
|
|
|
- }, 60_000)
|
|
|
+ await dialog.getByText('Close', { exact: true }).click()
|
|
|
+
|
|
|
+ const observer = await newEnglishPage(browser)
|
|
|
+ const observerTripwire = watchConsole(observer)
|
|
|
+ const observerSlotErrors: string[] = []
|
|
|
+ let observerDownloads = 0
|
|
|
+ observer.on('download', () => { observerDownloads += 1 })
|
|
|
+ observer.on('console', (message) => {
|
|
|
+ if (message.type() === 'error' && /slot entry crashed/i.test(message.text())) {
|
|
|
+ observerSlotErrors.push(message.text())
|
|
|
+ }
|
|
|
+ })
|
|
|
+ const observerSessionBaseline = baselineResponse(observer, 'session.list')
|
|
|
+ const observerWorkspaceBaseline = baselineResponse(observer, 'workspace.list')
|
|
|
+ const [, observerSessionResponse, observerWorkspaceResponse] = await Promise.all([
|
|
|
+ observer.goto(scaffold.baseUrl, { waitUntil: 'load' }),
|
|
|
+ observerSessionBaseline,
|
|
|
+ observerWorkspaceBaseline,
|
|
|
+ ])
|
|
|
+ await Promise.all([
|
|
|
+ assertBaselineSucceeded(observerSessionResponse, 'observer session.list'),
|
|
|
+ assertBaselineSucceeded(observerWorkspaceResponse, 'observer workspace.list'),
|
|
|
+ ])
|
|
|
+ await observer.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
|
|
|
+ await ensureSeedOpen(observer)
|
|
|
+
|
|
|
+ try {
|
|
|
+ const input = page.locator('textarea').first()
|
|
|
+ const slashDownloadPromise = page.waitForEvent('download', { timeout: 30_000 })
|
|
|
+ await input.fill('/export')
|
|
|
+ await page.getByRole('option', { name: /export/u }).waitFor({ timeout: 10_000 })
|
|
|
+ await input.press('Enter')
|
|
|
+ const slashDownload = await slashDownloadPromise
|
|
|
+ expect(slashDownload.suggestedFilename()).toBe(download.suggestedFilename())
|
|
|
+ const slashFiles = unzipSync(await readFile(await slashDownload.path()))
|
|
|
+ const slashContent = strFromU8(slashFiles['session.jsonl'] as Uint8Array)
|
|
|
+ const slashEvents = parseSessionLog(slashContent)
|
|
|
+ const exportRun = slashEvents.findLast(event => event.type === 'command/run' && event.data.name === 'export')
|
|
|
+ if (exportRun?.type !== 'command/run') throw new Error('slash ZIP has no export command/run')
|
|
|
+ const exportDone = slashEvents.find(event =>
|
|
|
+ event.type === 'command/done' && event.data.commandId === exportRun.data.commandId)
|
|
|
+ expect(exportDone?.type).toBe('command/done')
|
|
|
+ await page.getByRole('dialog', { name: 'Session download started' }).waitFor({ timeout: 30_000 })
|
|
|
+ await page.getByRole('dialog', { name: 'Session download started' })
|
|
|
+ .getByText('Close', { exact: true }).click()
|
|
|
+ await observer.getByText('Session log download requested.', { exact: true }).waitFor({ timeout: 30_000 })
|
|
|
+ expect(observerDownloads).toBe(0)
|
|
|
+ expect(await observer.getByRole('dialog', { name: 'Session download started' }).count()).toBe(0)
|
|
|
+ expect({
|
|
|
+ pageErrors: observerTripwire.pageErrors,
|
|
|
+ slotErrors: observerSlotErrors,
|
|
|
+ warnings: observerTripwire.warnings,
|
|
|
+ }).toEqual({ pageErrors: [], slotErrors: [], warnings: [] })
|
|
|
+ } finally {
|
|
|
+ await observer.close()
|
|
|
+ }
|
|
|
+ }, 120_000)
|
|
|
|
|
|
it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => {
|
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline'))
|