Files
hermes-agent/apps/desktop/src/lib/persisted.test.ts
T
Zeus-Deus 26b4315e09 fix(desktop): profile-scope persisted unread and stop cold-boot storage clobber
Address review on the persisted unread dots, plus a latent data-loss bug
in the shared persistence helper that the restart e2e exposed.

Review findings:

- Session ids are caller-supplied and each profile backend is its own
  namespace, while the desktop's lists routinely mix profiles (cron and
  messaging slices are always cross-profile; recents are too in
  all-profiles mode). Both persisted records are now bucketed per
  profile - nested records keyed by the ROW's own profile
  (normalizeProfileKey, absent -> default), never the live gateway's,
  except the live busy->idle edge with no loaded row, which can only
  come from the active gateway. Same-id sessions in different profiles
  no longer share watermarks or markers.
- Markers are now bounded (200 per profile, oldest evicted) and cleaned
  up when a session leaves the user's world: forgetSessionUnread() is
  wired into removeSession, archiveSession, and the settings
  permanent-delete path (which bypasses the other two).

Cold-boot clobber (found by the restart e2e after the refactor):

- persistentAtom wrote its value back to storage immediately at
  creation. On a cold boot the bundle can evaluate against a storage
  snapshot that has not caught up yet, so that echo overwrote real
  records with the fallback. Creation is now read-only; only actual
  changes persist. Regression-tested in persisted.test.ts.
- The read side of the same race is handled in session-unread.ts: the
  first list arrival re-reads both records from storage (readable by
  then) and merges them under the in-memory state, so a boot that
  seeded empty atoms adopts the disk state instead of re-seeding every
  row and burying the unread gap. Unread listeners are also disabled in
  secondary windows - their partial list view must not write the
  primary's whole-record state (same isolation rule as session tiles).

Tests: cross-profile same-id regression, live-edge profile fallback,
forgetSessionUnread cleanup, marker cap, persistentAtom creation
read-only; the restart e2e passes again end to end.
2026-08-15 01:21:40 -07:00

67 lines
2.1 KiB
TypeScript

import { beforeEach, describe, expect, it } from 'vitest'
import { Codecs, persistentAtom } from './persisted'
// Unique key per test run — persistentAtom instances are singletons per call,
// so tests mint fresh keys instead of sharing one.
let counter = 0
const freshKey = () => `test.persisted.${++counter}`
describe('persistentAtom', () => {
beforeEach(() => {
window.localStorage.clear()
})
it('seeds from a stored value', () => {
const key = freshKey()
window.localStorage.setItem(key, JSON.stringify({ a: 1 }))
const $value = persistentAtom<Record<string, number>>(key, {})
expect($value.get()).toEqual({ a: 1 })
})
it('does NOT write at creation — an empty read must never clobber storage', () => {
// A cold boot can evaluate the bundle against a storage snapshot that has
// not caught up yet (early hidden/boot load). If creation echoed the
// fallback back out, that load would overwrite the real record other
// loads are about to read. Creation must be read-only.
const key = freshKey()
persistentAtom<Record<string, number>>(key, {})
expect(window.localStorage.getItem(key)).toBeNull()
})
it('leaves an existing stored value untouched at creation even when it fails to decode', () => {
const key = freshKey()
window.localStorage.setItem(key, 'not json')
const $value = persistentAtom<Record<string, number>>(key, { fallback: 1 })
expect($value.get()).toEqual({ fallback: 1 })
// The corrupt value survives until a real write replaces it — creation
// never writes.
expect(window.localStorage.getItem(key)).toBe('not json')
})
it('persists real changes', () => {
const key = freshKey()
const $value = persistentAtom<Record<string, number>>(key, {})
$value.set({ b: 2 })
expect(window.localStorage.getItem(key)).toBe(JSON.stringify({ b: 2 }))
})
it('removes the key when the codec encodes null', () => {
const key = freshKey()
window.localStorage.setItem(key, JSON.stringify(['x']))
const $value = persistentAtom<string[]>(key, [], Codecs.stringArray)
$value.set([])
expect(window.localStorage.getItem(key)).toBeNull()
})
})