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.
191 lines
7.4 KiB
TypeScript
191 lines
7.4 KiB
TypeScript
/**
|
|
* test/sw-push.test.ts — Unit tests for public/sw-push.js pure helpers.
|
|
*
|
|
* Covers T-sw W1: buildPushNotification (all NotifyClass values) and
|
|
* resolveNotificationClick (allow / deny / body-click routing).
|
|
*
|
|
* Security: SEC-C1 (decision body carries token), SEC-M6 (tag=sessionId dedup).
|
|
* No DOM or SW globals needed — sw-push.js is intentionally pure.
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest'
|
|
import type { PushPayload } from '../src/types.js'
|
|
|
|
// sw-push.js is a plain ES-module JS file; vitest imports it as ESM.
|
|
// We assert the expected return shapes inline.
|
|
const { buildPushNotification, resolveNotificationClick } = await import(
|
|
'../public/sw-push.js'
|
|
)
|
|
|
|
// ─────────────── helpers ───────────────────────────────────────────────────
|
|
|
|
/** Minimal notification mock matching the shape sw-push.js reads. */
|
|
function fakeNotification(data: Record<string, unknown>): Notification {
|
|
return { data } as unknown as Notification
|
|
}
|
|
|
|
// ─────────────── buildPushNotification ────────────────────────────────────
|
|
|
|
describe('buildPushNotification', () => {
|
|
it('needs-input: requireInteraction=true, Allow+Deny actions, tag=sessionId', () => {
|
|
const payload: PushPayload = {
|
|
sessionId: 'sess-001',
|
|
toolName: 'Bash',
|
|
detail: 'rm -rf /tmp/cache',
|
|
token: 'tok-aaa',
|
|
cls: 'needs-input',
|
|
}
|
|
const { title, options } = buildPushNotification(payload)
|
|
|
|
expect(title).toBeTruthy()
|
|
expect(typeof title).toBe('string')
|
|
expect(options.tag).toBe('sess-001') // SEC-M6
|
|
expect(options.requireInteraction).toBe(true)
|
|
const actions: Array<{ action: string; title: string }> = options.actions ?? []
|
|
expect(actions.length).toBeGreaterThanOrEqual(2)
|
|
expect(actions.some((a) => a.action === 'allow')).toBe(true)
|
|
expect(actions.some((a) => a.action === 'deny')).toBe(true)
|
|
expect(options.data).toMatchObject({
|
|
sessionId: 'sess-001',
|
|
token: 'tok-aaa',
|
|
cls: 'needs-input',
|
|
})
|
|
})
|
|
|
|
it('needs-input without detail falls back to toolName in body', () => {
|
|
const payload: PushPayload = {
|
|
sessionId: 's',
|
|
toolName: 'Edit',
|
|
token: 'tok-bbb',
|
|
cls: 'needs-input',
|
|
}
|
|
const { options } = buildPushNotification(payload)
|
|
expect(options.body ?? '').toContain('Edit')
|
|
})
|
|
|
|
it('done: no requireInteraction, no actions, tag=sessionId', () => {
|
|
const payload: PushPayload = { sessionId: 'sess-002', cls: 'done' }
|
|
const { title, options } = buildPushNotification(payload)
|
|
|
|
expect(title).toBeTruthy()
|
|
expect(options.tag).toBe('sess-002') // SEC-M6
|
|
expect(options.requireInteraction).toBeFalsy()
|
|
expect((options.actions ?? []).length).toBe(0)
|
|
expect(options.data).toMatchObject({ sessionId: 'sess-002', cls: 'done' })
|
|
})
|
|
|
|
it('done: body includes detail when provided', () => {
|
|
const payload: PushPayload = {
|
|
sessionId: 'sess-003',
|
|
detail: 'Build succeeded',
|
|
cls: 'done',
|
|
}
|
|
const { options } = buildPushNotification(payload)
|
|
expect(options.body).toContain('Build succeeded')
|
|
})
|
|
|
|
it('stuck: no requireInteraction, no actions, tag=sessionId', () => {
|
|
const payload: PushPayload = { sessionId: 'sess-004', cls: 'stuck' }
|
|
const { title, options } = buildPushNotification(payload)
|
|
|
|
expect(title).toBeTruthy()
|
|
expect(options.tag).toBe('sess-004') // SEC-M6
|
|
expect(options.requireInteraction).toBeFalsy()
|
|
expect((options.actions ?? []).length).toBe(0)
|
|
expect(options.data).toMatchObject({ sessionId: 'sess-004', cls: 'stuck' })
|
|
})
|
|
|
|
it('no detail and no toolName → body is undefined or empty', () => {
|
|
const payload: PushPayload = { sessionId: 'sess-005', cls: 'done' }
|
|
const { options } = buildPushNotification(payload)
|
|
// body should be undefined or an empty string — not a thrown error
|
|
expect(() => buildPushNotification(payload)).not.toThrow()
|
|
const body = options.body
|
|
expect(body === undefined || body === '').toBe(true)
|
|
})
|
|
})
|
|
|
|
// ─────────────── resolveNotificationClick ─────────────────────────────────
|
|
|
|
describe('resolveNotificationClick', () => {
|
|
it('allow action → decision with decision=allow and capability token (SEC-C1)', () => {
|
|
const notification = fakeNotification({
|
|
sessionId: 'sess-10',
|
|
token: 'tok-xyz',
|
|
cls: 'needs-input',
|
|
})
|
|
const result = resolveNotificationClick(notification, 'allow')
|
|
|
|
expect(result.kind).toBe('decision')
|
|
const body = JSON.parse(result.body) as Record<string, unknown>
|
|
expect(body.sessionId).toBe('sess-10')
|
|
expect(body.decision).toBe('allow')
|
|
expect(body.token).toBe('tok-xyz') // SEC-C1: token forwarded
|
|
})
|
|
|
|
it('deny action → decision with decision=deny', () => {
|
|
const notification = fakeNotification({
|
|
sessionId: 'sess-11',
|
|
token: 'tok-abc',
|
|
cls: 'needs-input',
|
|
})
|
|
const result = resolveNotificationClick(notification, 'deny')
|
|
|
|
expect(result.kind).toBe('decision')
|
|
const body = JSON.parse(result.body) as Record<string, unknown>
|
|
expect(body.sessionId).toBe('sess-11')
|
|
expect(body.decision).toBe('deny')
|
|
expect(body.token).toBe('tok-abc')
|
|
})
|
|
|
|
it('empty action string (body click) → focus with session URL', () => {
|
|
const notification = fakeNotification({ sessionId: 'sess-12' })
|
|
const result = resolveNotificationClick(notification, '')
|
|
|
|
expect(result.kind).toBe('focus')
|
|
expect(result.url).toBe('/?session=sess-12')
|
|
})
|
|
|
|
it('undefined action (body click) → focus with session URL', () => {
|
|
const notification = fakeNotification({ sessionId: 'sess-13' })
|
|
const result = resolveNotificationClick(notification, undefined)
|
|
|
|
expect(result.kind).toBe('focus')
|
|
expect(result.url).toBe('/?session=sess-13')
|
|
})
|
|
|
|
it('null action → focus (treated as default body click)', () => {
|
|
const notification = fakeNotification({ sessionId: 'sess-14' })
|
|
const result = resolveNotificationClick(notification, null)
|
|
|
|
expect(result.kind).toBe('focus')
|
|
expect(result.url).toContain('sess-14')
|
|
})
|
|
|
|
it('token is undefined in body when not in notification data (done notification)', () => {
|
|
// done/stuck notifications have no token in data; test edge: action='allow' still
|
|
// sends a decision body but token field is absent (server should reject gracefully)
|
|
const notification = fakeNotification({ sessionId: 'sess-15', cls: 'done' })
|
|
const result = resolveNotificationClick(notification, 'allow')
|
|
|
|
expect(result.kind).toBe('decision')
|
|
const body = JSON.parse(result.body) as Record<string, unknown>
|
|
expect(body.token).toBeUndefined()
|
|
})
|
|
|
|
it('sessionId is URL-encoded in focus URL (special characters safe)', () => {
|
|
const notification = fakeNotification({ sessionId: 'sess a+b' })
|
|
const result = resolveNotificationClick(notification, '')
|
|
|
|
expect(result.kind).toBe('focus')
|
|
// encodeURIComponent encodes spaces and + signs
|
|
expect(result.url).not.toContain(' ')
|
|
})
|
|
|
|
it('missing data on notification → focus URL with empty session gracefully', () => {
|
|
// notification.data is null/undefined
|
|
const notification = { data: null } as unknown as Notification
|
|
expect(() => resolveNotificationClick(notification, '')).not.toThrow()
|
|
})
|
|
})
|