d2c2b0b6d4
The green "finished — unread" session dot lived only in the transient $unreadFinishedSessionIds atom, written by a live busy->idle edge the renderer had to witness. Closing and reopening the app grayed out every dot, and a session that finished while the app was closed could never be flagged at all. Add a persisted layer (session-unread.ts), ported from the webui's proven design: - Seen watermarks (hermes.desktop.sessionSeenCounts): the message_count last acknowledged per session, keyed by the durable lineage id (same rule as session colors). A row whose live count exceeds its watermark paints unread on every list refresh - this reconstructs dots after a restart AND surfaces sessions that finished while the app was closed. First sight of an unknown session seeds the watermark so a fresh install doesn't light up every row. - Explicit finish markers (hermes.desktop.unreadFinishedSessions): the live edge, persisted, covering the gap before the sidebar list refreshes its counts. Opening a session acks both; the selected session's watermark tracks its live count so on-screen activity never reads as unread. Chat and cron rows get full watermark treatment; messaging rows keep explicit markers only, so inbound messages don't paint false completion dots. Profile switches keep persisted markers (keyed by durable id) and only wipe the transient paint layer, so a round-trip repaints them. Covered by store unit tests and an e2e spec that boots the app three times: dot appears on a background finish, survives a restart, clears on open, and stays cleared after another restart.
163 lines
6.1 KiB
TypeScript
163 lines
6.1 KiB
TypeScript
/**
|
|
* E2E test for PERSISTED unread state — the green "Finished — unread" dot
|
|
* must survive an app restart.
|
|
*
|
|
* Regression coverage for the reset-on-restart bug: the unread flag used to
|
|
* live only in the in-memory `$unreadFinishedSessionIds` atom, so closing and
|
|
* reopening the desktop app grayed out every green dot. The persisted layer
|
|
* (src/store/session-unread.ts) now rebuilds the dot from localStorage-backed
|
|
* finish markers + seen-count watermarks.
|
|
*
|
|
* The scenario uses TWO sessions on purpose: with a single session the app
|
|
* can reopen straight into it after a restart, which acks the session (the
|
|
* user is looking at it) and would mask the dot. Session A finishes in the
|
|
* background while session B is the selected one; the restart reopens into
|
|
* B, so A's dot is observable.
|
|
*
|
|
* 1. Session A: start a turn, hold its stream open.
|
|
* 2. Session B: new session, send a message, let it finish while SELECTED.
|
|
* 3. Release A's stream → its background finish paints A's green dot.
|
|
* 4. QUIT the app, relaunch on the SAME sandbox → A's dot must still be
|
|
* there (previously it was lost).
|
|
* 5. Open session A → dot clears.
|
|
* 6. Restart once more → the cleared state must persist too (no zombie dot).
|
|
*
|
|
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
|
*/
|
|
|
|
import { expect, type Page, test } from '@playwright/test'
|
|
import { type ElectronApplication } from '@playwright/test'
|
|
|
|
import {
|
|
buildAppEnv,
|
|
launchDesktop,
|
|
type MockBackendFixture,
|
|
setupMockBackend,
|
|
waitForAppReady,
|
|
} from './fixtures'
|
|
import { restartMockServer } from './mock-server'
|
|
|
|
/** Finished-unread dot aria-label (from i18n en.ts). */
|
|
const UNREAD_DOT_LABEL = 'Finished — unread'
|
|
|
|
/** Held prompt — the mock pauses this stream until we release it, so the
|
|
* turn is deterministically still running when we switch away. */
|
|
const HELD_PROMPT = 'E2E unread restart: hold this stream until released.'
|
|
/** Second session's prompt — completes normally while selected. */
|
|
const SECOND_PROMPT = 'E2E unread restart: second session, read while open.'
|
|
|
|
const unreadDots = (page: Page) => page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`)
|
|
|
|
async function sendMessage(page: Page, text: string): Promise<void> {
|
|
const composer = page.locator('[contenteditable="true"]').first()
|
|
await composer.waitFor({ state: 'visible', timeout: 15_000 })
|
|
await composer.click()
|
|
await composer.type(text, { delay: 5 })
|
|
await page.keyboard.press('Enter')
|
|
}
|
|
|
|
test.describe('unread dot survives app restart', () => {
|
|
test.describe.configure({ mode: 'serial' })
|
|
// Three full app boots in one scenario — give it more than the global 90s.
|
|
test.setTimeout(300_000)
|
|
|
|
let fixture: MockBackendFixture
|
|
let app: ElectronApplication
|
|
let page: Page
|
|
|
|
test.beforeAll(async () => {
|
|
restartMockServer()
|
|
fixture = await setupMockBackend({
|
|
mockServer: { holdFirstStreamForPrompt: HELD_PROMPT },
|
|
})
|
|
app = fixture.app
|
|
page = fixture.page
|
|
await waitForAppReady(fixture, 120_000)
|
|
})
|
|
|
|
test.afterAll(async () => {
|
|
// The fixture's own app handle may already be closed by the restart
|
|
// steps — close whatever is current, then drop the sandbox + mock.
|
|
try {
|
|
await app?.close()
|
|
} catch {
|
|
// already closed
|
|
}
|
|
|
|
fixture?.mock.close()
|
|
fixture?.sandbox.cleanup()
|
|
})
|
|
|
|
/** Relaunch the desktop app against the SAME sandbox (same userData →
|
|
* same localStorage, same HERMES_HOME → same session store). */
|
|
async function restartApp(): Promise<void> {
|
|
await app.close()
|
|
|
|
const relaunched = await launchDesktop(buildAppEnv(fixture.sandbox))
|
|
app = relaunched.app
|
|
page = relaunched.page
|
|
await waitForAppReady({ ...fixture, app, page }, 120_000)
|
|
}
|
|
|
|
test('green dot persists across restart and its clear persists too', async () => {
|
|
// ── 1. Session A: start a turn whose stream the mock holds open ────
|
|
await sendMessage(page, HELD_PROMPT)
|
|
await fixture.mock.waitForHeldStream()
|
|
|
|
// ── 2. Session B: complete a turn while SELECTED (stays read) ──────
|
|
await page.locator('button:has-text("New session")').first().click()
|
|
await sendMessage(page, SECOND_PROMPT)
|
|
|
|
// B's reply lands in the open transcript — this session is "read".
|
|
await page.waitForFunction(
|
|
() => (document.body.textContent ?? '').includes('Hello from the mock inference server'),
|
|
undefined,
|
|
{ timeout: 30_000 },
|
|
)
|
|
|
|
// ── 3. Release A's stream → background finish paints A's dot ──────
|
|
fixture.mock.releaseHeldStream()
|
|
|
|
await expect
|
|
.poll(() => unreadDots(page).count(), {
|
|
timeout: 30_000,
|
|
message: 'green unread dot should appear after the background finish',
|
|
})
|
|
.toBeGreaterThan(0)
|
|
|
|
// ── 4. Restart the app — the dot must survive ──────────────────────
|
|
// The app reopens into session B (or a fresh draft), NOT session A, so
|
|
// A's dot is observable rather than being acked by the route restore.
|
|
await restartApp()
|
|
|
|
await expect
|
|
.poll(() => unreadDots(page).count(), {
|
|
timeout: 60_000,
|
|
message: 'green unread dot should be rebuilt from persisted state after restart',
|
|
})
|
|
.toBeGreaterThan(0)
|
|
|
|
// ── 5. Open session A — the dot clears ─────────────────────────────
|
|
// The dot sits inside A's sidebar row button; click that row.
|
|
await unreadDots(page)
|
|
.first()
|
|
.locator('xpath=ancestor::button[1]')
|
|
.click()
|
|
|
|
await expect
|
|
.poll(() => unreadDots(page).count(), {
|
|
timeout: 15_000,
|
|
message: 'opening the session should clear its unread dot',
|
|
})
|
|
.toBe(0)
|
|
|
|
// ── 6. Restart again — the CLEARED state must persist as well ──────
|
|
await restartApp()
|
|
|
|
// Give the sidebar a moment to load rows, then assert no dot returns.
|
|
await page.waitForSelector('[data-slot="sidebar"]', { timeout: 60_000 })
|
|
await page.waitForTimeout(5_000)
|
|
expect(await unreadDots(page).count(), 'acked session must stay read after restart').toBe(0)
|
|
})
|
|
})
|