Implements docs/PLAN_WALKAWAY_WORKBENCH.md (27 tasks, waves R0→W0→W1×14→W2→W3→W4)
via module-builder agents. 23 tasks built, 0 blocked.
Band A (finish the walk-away loop): A1 Web Push + lock-screen approve/deny
(web-push dep), A2 voice dictation, A3 quick-reply chips + saved-prompt palette,
A4 activity timeline, A5 stuck/idle alert.
Band B (workbench above the terminal): B1 read-only git diff viewer, B2 statusLine
telemetry → per-tab cost/context/PR gauges, B3 create git worktrees from the UI,
B4 plan-mode / permission-mode relay.
New: src/push/* (subscription store + VAPID push), src/http/{diff,statusline}.ts,
src/session/timeline.ts, public/{diff,timeline,quickreply,push-ui,...}.ts, sw-push,
statusLine script; extends hook intake, manager, server routes (Origin/CSRF guards
+ per-IP rate limits on state-changing ones; loopback-only ingest), terminal-session,
tabs, projects detail, service worker, setup-hooks (statusLine + ntfy bridge).
Orchestrator reconciled a W0 contract gap: added the 21 v0.7 Config fields to the
Config interface in types.ts (T-types had left them only in config.ts's return).
Verified: both tsc clean, full vitest + coverage 91.4/84.1/92.2/93.4 (≥80×4),
build:web OK. W4 review: no CRITICAL/HIGH; all security checks pass. Follow-ups
(non-blocking): move approve.mode validation into parseClientMessage, drop CSP
ws:/wss: wildcard, validate worktree base ref, +2 targeted tests.
171 lines
6.5 KiB
TypeScript
171 lines
6.5 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import type { PushSubscriptionRecord } from '../../src/types.js'
|
|
import {
|
|
isValidSubscriptionRecord,
|
|
loadSubscriptionStore,
|
|
} from '../../src/push/subscription-store.js'
|
|
|
|
const dirs: string[] = []
|
|
|
|
function tmpFile(name = 'push-subs.json'): string {
|
|
const dir = mkdtempSync(join(tmpdir(), 'webterm-subs-'))
|
|
dirs.push(dir)
|
|
return join(dir, name)
|
|
}
|
|
|
|
function rec(endpoint: string, createdAt = 1000): PushSubscriptionRecord {
|
|
return { endpoint, keys: { p256dh: 'pub-' + endpoint, auth: 'auth-' + endpoint }, createdAt }
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
describe('isValidSubscriptionRecord', () => {
|
|
it('accepts a well-formed record', () => {
|
|
expect(isValidSubscriptionRecord(rec('https://push/1'))).toBe(true)
|
|
})
|
|
|
|
it('rejects non-objects and missing/invalid fields', () => {
|
|
expect(isValidSubscriptionRecord(null)).toBe(false)
|
|
expect(isValidSubscriptionRecord('x')).toBe(false)
|
|
expect(isValidSubscriptionRecord({})).toBe(false)
|
|
expect(isValidSubscriptionRecord({ endpoint: '', keys: { p256dh: 'a', auth: 'b' }, createdAt: 1 })).toBe(false)
|
|
expect(isValidSubscriptionRecord({ endpoint: 'e', keys: { p256dh: 'a' }, createdAt: 1 })).toBe(false)
|
|
expect(isValidSubscriptionRecord({ endpoint: 'e', keys: { p256dh: 1, auth: 'b' }, createdAt: 1 })).toBe(false)
|
|
expect(isValidSubscriptionRecord({ endpoint: 'e', keys: { p256dh: 'a', auth: 'b' } })).toBe(false)
|
|
expect(isValidSubscriptionRecord({ endpoint: 'e', keys: { p256dh: 'a', auth: 'b' }, createdAt: NaN })).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('loadSubscriptionStore — loading', () => {
|
|
it('starts empty when the file is missing', () => {
|
|
const store = loadSubscriptionStore(tmpFile(), 50)
|
|
expect(store.list()).toEqual([])
|
|
})
|
|
|
|
it('starts empty (and logs) on malformed JSON', () => {
|
|
const err = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const path = tmpFile()
|
|
writeFileSync(path, '{ not valid json')
|
|
const store = loadSubscriptionStore(path, 50)
|
|
expect(store.list()).toEqual([])
|
|
expect(err).toHaveBeenCalled()
|
|
})
|
|
|
|
it('filters out invalid records on load', () => {
|
|
const path = tmpFile()
|
|
writeFileSync(
|
|
path,
|
|
JSON.stringify([rec('https://push/ok'), { endpoint: 123 }, { foo: 'bar' }]),
|
|
)
|
|
const store = loadSubscriptionStore(path, 50)
|
|
expect(store.list().map((r) => r.endpoint)).toEqual(['https://push/ok'])
|
|
})
|
|
|
|
it('returns empty when the JSON is not an array', () => {
|
|
const path = tmpFile()
|
|
writeFileSync(path, JSON.stringify({ endpoint: 'x' }))
|
|
expect(loadSubscriptionStore(path, 50).list()).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('SubscriptionStore — list immutability', () => {
|
|
it('returns a frozen copy that cannot be mutated', () => {
|
|
const store = loadSubscriptionStore(tmpFile(), 50)
|
|
store.add(rec('https://push/1'))
|
|
const snapshot = store.list()
|
|
expect(Object.isFrozen(snapshot)).toBe(true)
|
|
expect(() => (snapshot as PushSubscriptionRecord[]).push(rec('https://push/2'))).toThrow()
|
|
// original store is unaffected
|
|
expect(store.list()).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
describe('SubscriptionStore — add', () => {
|
|
it('appends valid records', () => {
|
|
const store = loadSubscriptionStore(tmpFile(), 50)
|
|
store.add(rec('https://push/1'))
|
|
store.add(rec('https://push/2'))
|
|
expect(store.list().map((r) => r.endpoint)).toEqual(['https://push/1', 'https://push/2'])
|
|
})
|
|
|
|
it('throws on an invalid record (fail-fast at the boundary)', () => {
|
|
const store = loadSubscriptionStore(tmpFile(), 50)
|
|
expect(() => store.add({ endpoint: '' } as unknown as PushSubscriptionRecord)).toThrow()
|
|
})
|
|
|
|
it('deduplicates by endpoint, replacing the prior record', () => {
|
|
const store = loadSubscriptionStore(tmpFile(), 50)
|
|
store.add(rec('https://push/1', 1000))
|
|
store.add({ endpoint: 'https://push/1', keys: { p256dh: 'new', auth: 'new' }, createdAt: 2000 })
|
|
const list = store.list()
|
|
expect(list).toHaveLength(1)
|
|
expect(list[0]?.keys.p256dh).toBe('new')
|
|
expect(list[0]?.createdAt).toBe(2000)
|
|
})
|
|
|
|
it('enforces a FIFO cap at maxSubs, evicting the oldest', () => {
|
|
const store = loadSubscriptionStore(tmpFile(), 2)
|
|
store.add(rec('https://push/1'))
|
|
store.add(rec('https://push/2'))
|
|
store.add(rec('https://push/3'))
|
|
expect(store.list().map((r) => r.endpoint)).toEqual(['https://push/2', 'https://push/3'])
|
|
})
|
|
})
|
|
|
|
describe('SubscriptionStore — remove / prune', () => {
|
|
it('removes a record by endpoint and is a no-op for unknown endpoints', () => {
|
|
const store = loadSubscriptionStore(tmpFile(), 50)
|
|
store.add(rec('https://push/1'))
|
|
store.add(rec('https://push/2'))
|
|
store.remove('https://push/1')
|
|
store.remove('https://push/absent')
|
|
expect(store.list().map((r) => r.endpoint)).toEqual(['https://push/2'])
|
|
})
|
|
|
|
it('prunes a batch of dead endpoints', () => {
|
|
const store = loadSubscriptionStore(tmpFile(), 50)
|
|
store.add(rec('https://push/1'))
|
|
store.add(rec('https://push/2'))
|
|
store.add(rec('https://push/3'))
|
|
store.prune(['https://push/1', 'https://push/3'])
|
|
expect(store.list().map((r) => r.endpoint)).toEqual(['https://push/2'])
|
|
})
|
|
})
|
|
|
|
describe('SubscriptionStore — persist', () => {
|
|
it('writes the file with 0600 permissions and round-trips through load', async () => {
|
|
const path = tmpFile()
|
|
const store = loadSubscriptionStore(path, 50)
|
|
store.add(rec('https://push/1', 11))
|
|
store.add(rec('https://push/2', 22))
|
|
await store.persist()
|
|
|
|
const mode = statSync(path).mode & 0o777
|
|
expect(mode).toBe(0o600)
|
|
|
|
const onDisk = JSON.parse(readFileSync(path, 'utf8'))
|
|
expect(onDisk.map((r: PushSubscriptionRecord) => r.endpoint)).toEqual([
|
|
'https://push/1',
|
|
'https://push/2',
|
|
])
|
|
|
|
const reloaded = loadSubscriptionStore(path, 50)
|
|
expect(reloaded.list().map((r) => r.endpoint)).toEqual(['https://push/1', 'https://push/2'])
|
|
})
|
|
|
|
it('logs but does not throw when the destination is unwritable', async () => {
|
|
const err = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
// a path whose parent directory does not exist
|
|
const store = loadSubscriptionStore(join(tmpFile(), 'nope', 'subs.json'), 50)
|
|
store.add(rec('https://push/1'))
|
|
await expect(store.persist()).resolves.toBeUndefined()
|
|
expect(err).toHaveBeenCalled()
|
|
})
|
|
})
|