Files
web-terminal/test/prefs-store.test.ts
Yaojia Wang 03612323c0 feat(v0.6): auto-group Projects by namespace + Active-now band + cross-device prefs
The flat Projects grid was overwhelming at 50-100 repos. Explored the UX with
4 parallel agents (auto-grouping / manual folders / density-segmentation /
competitive research); all converged on namespace auto-grouping as the primary
fix — the dotted repo names (Billo.Platform.*, Billo.Infrastructure.*) already
encode the hierarchy, so it's a string-split, not a taxonomy the user builds.

Frontend:
- groupProjects(): depth-2 namespace grouping, >=2-member threshold (singletons
  fall to "Other"), degrade-to-flat when repos share no prefix, running projects
  duplicated into a pinned "Active now" section.
- displayLabel(): cards show the namespace tail (Payment, not
  Billo.Platform.Payment); full name in Active/Other.
- Collapsible sticky group headers with an "N active" badge so a collapsed
  section never hides running work; search force-expands matching groups.
- public/prefs.ts: server-first load with a localStorage offline mirror, migrates
  the legacy per-device proj-favs.

Backend (server-side prefs so favourites + collapse-state follow the user across
devices — localStorage was per-device, which broke the multi-device premise):
- src/http/prefs-store.ts: ~/.web-terminal-prefs.json store (mirrors push-store),
  every field bounded + sanitized.
- GET /prefs (read-only) + PUT /prefs (Origin-guarded); UiPrefs type +
  PREFS_STORE_PATH config.

Tests: +12 prefs-store, +21 groupProjects/displayLabel. Full suite 1227 passed.
Verified live: grouping renders, collapse persists to the server and survives a
full page reload (cross-device sync proven end-to-end).
2026-07-01 04:25:57 +02:00

103 lines
3.8 KiB
TypeScript

/**
* test/prefs-store.test.ts — unit tests for the Projects UI prefs store.
*
* Covers sanitizePrefs (untrusted coercion + caps) and loadPrefsStore
* (missing file → defaults, malformed → defaults, set/get/persist roundtrip).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { sanitizePrefs, emptyPrefs, loadPrefsStore } from '../src/http/prefs-store.js'
describe('sanitizePrefs', () => {
it('returns empty prefs for non-object input', () => {
expect(sanitizePrefs(null)).toEqual({ favourites: [], collapsed: {} })
expect(sanitizePrefs(42)).toEqual(emptyPrefs())
expect(sanitizePrefs('x')).toEqual(emptyPrefs())
expect(sanitizePrefs([])).toEqual(emptyPrefs())
})
it('keeps only string favourites and de-dups them', () => {
const out = sanitizePrefs({ favourites: ['/a', '/a', '/b', 3, null, ''] })
expect(out.favourites).toEqual(['/a', '/b'])
})
it('keeps only collapsed entries whose value is exactly true', () => {
const out = sanitizePrefs({ collapsed: { g1: true, g2: false, g3: 'yes', g4: 1 } })
expect(out.collapsed).toEqual({ g1: true })
})
it('ignores an array passed as collapsed', () => {
expect(sanitizePrefs({ collapsed: ['g1'] }).collapsed).toEqual({})
})
it('caps favourites at the maximum', () => {
const many = Array.from({ length: 600 }, (_, i) => `/p${i}`)
expect(sanitizePrefs({ favourites: many }).favourites).toHaveLength(500)
})
it('drops over-long strings', () => {
const long = 'x'.repeat(5000)
expect(sanitizePrefs({ favourites: [long, '/ok'] }).favourites).toEqual(['/ok'])
expect(sanitizePrefs({ collapsed: { [long]: true, ok: true } }).collapsed).toEqual({ ok: true })
})
})
describe('loadPrefsStore', () => {
let dir: string
let file: string
beforeEach(() => {
dir = mkdtempSync(path.join(tmpdir(), 'prefs-test-'))
file = path.join(dir, 'prefs.json')
})
afterEach(() => {
rmSync(dir, { recursive: true, force: true })
})
it('starts empty when the file is missing', () => {
const store = loadPrefsStore(file)
expect(store.get()).toEqual(emptyPrefs())
})
it('starts empty when the file is malformed JSON', () => {
writeFileSync(file, '{not json')
expect(loadPrefsStore(file).get()).toEqual(emptyPrefs())
})
it('loads and sanitizes existing prefs', () => {
writeFileSync(file, JSON.stringify({ favourites: ['/a', '/a'], collapsed: { g: true, h: false } }))
expect(loadPrefsStore(file).get()).toEqual({ favourites: ['/a'], collapsed: { g: true } })
})
it('get() returns a fresh copy (callers cannot mutate internal state)', () => {
const store = loadPrefsStore(file)
store.set({ favourites: ['/a'], collapsed: {} })
const a = store.get()
a.favourites.push('/hacked')
expect(store.get().favourites).toEqual(['/a'])
})
it('set() sanitizes untrusted input', () => {
const store = loadPrefsStore(file)
store.set({ favourites: ['/a', 5, ''], collapsed: { g: true, bad: 0 } })
expect(store.get()).toEqual({ favourites: ['/a'], collapsed: { g: true } })
})
it('persist() writes sanitized prefs that reload identically', async () => {
const store = loadPrefsStore(file)
store.set({ favourites: ['/x', '/y'], collapsed: { grp: true } })
await store.persist()
expect(existsSync(file)).toBe(true)
const reloaded = loadPrefsStore(file)
expect(reloaded.get()).toEqual({ favourites: ['/x', '/y'], collapsed: { grp: true } })
// sanity: file is valid JSON in the expected shape
expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual({
favourites: ['/x', '/y'],
collapsed: { grp: true },
})
})
})