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.
509 lines
16 KiB
TypeScript
509 lines
16 KiB
TypeScript
// @vitest-environment jsdom
|
|
/**
|
|
* test/push.test.ts — N-push-ui: push subscribe/permission UI
|
|
*
|
|
* Covers: PushSupportStatus states, fetchVapidKey, checkPushSupport,
|
|
* subscribePush, unsubscribePush, mountPushToggle, isPushMuted/setPushMuted.
|
|
*
|
|
* SEC-H8: isSecureContext gating.
|
|
* AC-A1.1, AC-A1.5: all support states; insecure context hidden/no crash; ≥80%.
|
|
* Review #12: localStorage mute is in-app-only (not server DND).
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
|
|
// ── Browser API helpers ───────────────────────────────────────────────────────
|
|
|
|
function setSecureContext(secure: boolean): void {
|
|
Object.defineProperty(window, 'isSecureContext', {
|
|
value: secure,
|
|
writable: true,
|
|
configurable: true,
|
|
})
|
|
}
|
|
|
|
interface MockPushSubscription {
|
|
endpoint: string
|
|
unsubscribe: ReturnType<typeof vi.fn>
|
|
toJSON: ReturnType<typeof vi.fn>
|
|
getKey: ReturnType<typeof vi.fn>
|
|
}
|
|
|
|
function makeMockSubscription(endpoint = 'https://push.example.com/sub'): MockPushSubscription {
|
|
return {
|
|
endpoint,
|
|
unsubscribe: vi.fn().mockResolvedValue(true),
|
|
toJSON: vi.fn().mockReturnValue({ endpoint, keys: { p256dh: 'pub', auth: 'auth' } }),
|
|
getKey: vi.fn().mockReturnValue(null),
|
|
}
|
|
}
|
|
|
|
function makeMockRegistration(subscription: MockPushSubscription | null = null) {
|
|
return {
|
|
pushManager: {
|
|
getSubscription: vi.fn().mockResolvedValue(subscription),
|
|
subscribe: vi.fn().mockResolvedValue(makeMockSubscription()),
|
|
},
|
|
}
|
|
}
|
|
|
|
function setServiceWorker(registration: ReturnType<typeof makeMockRegistration> | null = null) {
|
|
Object.defineProperty(navigator, 'serviceWorker', {
|
|
value: {
|
|
getRegistration: vi.fn().mockResolvedValue(registration),
|
|
},
|
|
writable: true,
|
|
configurable: true,
|
|
})
|
|
}
|
|
|
|
function setNotification(permission: 'default' | 'granted' | 'denied') {
|
|
vi.stubGlobal('Notification', {
|
|
permission,
|
|
requestPermission: vi.fn().mockResolvedValue(permission),
|
|
})
|
|
}
|
|
|
|
function removeNotification() {
|
|
// Simulate browsers without Notification API
|
|
Object.defineProperty(window, 'Notification', {
|
|
value: undefined,
|
|
writable: true,
|
|
configurable: true,
|
|
})
|
|
}
|
|
|
|
function setPushManagerPresent(present: boolean) {
|
|
Object.defineProperty(window, 'PushManager', {
|
|
value: present ? class PushManager {} : undefined,
|
|
writable: true,
|
|
configurable: true,
|
|
})
|
|
}
|
|
|
|
function mockFetch(body: unknown, status = 200) {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
json: vi.fn().mockResolvedValue(body),
|
|
}),
|
|
)
|
|
}
|
|
|
|
function mockFetchError() {
|
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error')))
|
|
}
|
|
|
|
// ── Setup: default "all supported" environment ────────────────────────────────
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks()
|
|
setSecureContext(true)
|
|
setPushManagerPresent(true)
|
|
setNotification('default')
|
|
setServiceWorker(makeMockRegistration(null))
|
|
mockFetch({ publicKey: 'dGVzdA' /* base64 "test" */ })
|
|
|
|
// Clear localStorage
|
|
try {
|
|
localStorage.clear()
|
|
} catch {
|
|
// ignore
|
|
}
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
// ── Dynamic import (after stubs are set up) ──────────────────────────────────
|
|
// We import at module level so vitest transpiles the TS file.
|
|
// The module reads globals at call-time, not at import time, so this is safe.
|
|
import {
|
|
fetchVapidKey,
|
|
checkPushSupport,
|
|
subscribePush,
|
|
unsubscribePush,
|
|
mountPushToggle,
|
|
isPushMuted,
|
|
setPushMuted,
|
|
} from '../public/push.js'
|
|
|
|
// ─────────────────────────── isPushMuted / setPushMuted ──────────────────────
|
|
|
|
describe('isPushMuted / setPushMuted (A1-FR9 in-app-only)', () => {
|
|
it('defaults to false when nothing is stored', () => {
|
|
expect(isPushMuted()).toBe(false)
|
|
})
|
|
|
|
it('returns true after setPushMuted(true)', () => {
|
|
setPushMuted(true)
|
|
expect(isPushMuted()).toBe(true)
|
|
})
|
|
|
|
it('returns false after setPushMuted(false) clears the value', () => {
|
|
setPushMuted(true)
|
|
setPushMuted(false)
|
|
expect(isPushMuted()).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ─────────────────────────── fetchVapidKey ───────────────────────────────────
|
|
|
|
describe('fetchVapidKey', () => {
|
|
it('returns the publicKey string on a 200 response', async () => {
|
|
mockFetch({ publicKey: 'my-vapid-public-key' })
|
|
const key = await fetchVapidKey()
|
|
expect(key).toBe('my-vapid-public-key')
|
|
})
|
|
|
|
it('returns null when server returns 503 (push disabled)', async () => {
|
|
mockFetch({}, 503)
|
|
const key = await fetchVapidKey()
|
|
expect(key).toBeNull()
|
|
})
|
|
|
|
it('returns null on network error', async () => {
|
|
mockFetchError()
|
|
const key = await fetchVapidKey()
|
|
expect(key).toBeNull()
|
|
})
|
|
|
|
it('returns null when response body is missing publicKey', async () => {
|
|
mockFetch({ wrong: 'shape' })
|
|
const key = await fetchVapidKey()
|
|
expect(key).toBeNull()
|
|
})
|
|
|
|
it('returns null when publicKey is not a string', async () => {
|
|
mockFetch({ publicKey: 123 })
|
|
const key = await fetchVapidKey()
|
|
expect(key).toBeNull()
|
|
})
|
|
})
|
|
|
|
// ─────────────────────────── checkPushSupport ────────────────────────────────
|
|
|
|
describe('checkPushSupport — insecure-context (SEC-H8)', () => {
|
|
it("returns 'insecure-context' when window.isSecureContext is false", async () => {
|
|
setSecureContext(false)
|
|
const status = await checkPushSupport('some-vapid-key')
|
|
expect(status).toBe('insecure-context')
|
|
})
|
|
})
|
|
|
|
describe('checkPushSupport — unsupported', () => {
|
|
it("returns 'unsupported' when serviceWorker is absent", async () => {
|
|
Object.defineProperty(navigator, 'serviceWorker', {
|
|
value: undefined,
|
|
writable: true,
|
|
configurable: true,
|
|
})
|
|
const status = await checkPushSupport('some-key')
|
|
expect(status).toBe('unsupported')
|
|
})
|
|
|
|
it("returns 'unsupported' when PushManager is absent", async () => {
|
|
setPushManagerPresent(false)
|
|
const status = await checkPushSupport('some-key')
|
|
expect(status).toBe('unsupported')
|
|
})
|
|
|
|
it("returns 'unsupported' when Notification is absent", async () => {
|
|
removeNotification()
|
|
const status = await checkPushSupport('some-key')
|
|
expect(status).toBe('unsupported')
|
|
})
|
|
})
|
|
|
|
describe('checkPushSupport — vapid-missing', () => {
|
|
it("returns 'vapid-missing' when vapidKey is null", async () => {
|
|
const status = await checkPushSupport(null)
|
|
expect(status).toBe('vapid-missing')
|
|
})
|
|
})
|
|
|
|
describe('checkPushSupport — permission-denied', () => {
|
|
it("returns 'permission-denied' when Notification.permission is 'denied'", async () => {
|
|
setNotification('denied')
|
|
const status = await checkPushSupport('some-key')
|
|
expect(status).toBe('permission-denied')
|
|
})
|
|
})
|
|
|
|
describe('checkPushSupport — subscribed', () => {
|
|
it("returns 'subscribed' when SW registration has an active subscription", async () => {
|
|
setServiceWorker(makeMockRegistration(makeMockSubscription()))
|
|
const status = await checkPushSupport('some-key')
|
|
expect(status).toBe('subscribed')
|
|
})
|
|
})
|
|
|
|
describe('checkPushSupport — available', () => {
|
|
it("returns 'available' when all checks pass and no subscription exists", async () => {
|
|
// Default: secure, supported, no subscription, permission default
|
|
const status = await checkPushSupport('some-key')
|
|
expect(status).toBe('available')
|
|
})
|
|
|
|
it("returns 'available' when permission is 'granted' but no subscription yet", async () => {
|
|
setNotification('granted')
|
|
const status = await checkPushSupport('some-key')
|
|
expect(status).toBe('available')
|
|
})
|
|
|
|
it("returns 'available' when SW registration returns null", async () => {
|
|
setServiceWorker(null)
|
|
const status = await checkPushSupport('some-key')
|
|
expect(status).toBe('available')
|
|
})
|
|
|
|
it("returns 'available' when pushManager.getSubscription throws", async () => {
|
|
const reg = makeMockRegistration(null)
|
|
reg.pushManager.getSubscription.mockRejectedValue(new Error('SW error'))
|
|
setServiceWorker(reg)
|
|
const status = await checkPushSupport('some-key')
|
|
expect(status).toBe('available')
|
|
})
|
|
})
|
|
|
|
describe('checkPushSupport — priority ordering', () => {
|
|
it('insecure-context wins over unsupported', async () => {
|
|
setSecureContext(false)
|
|
setPushManagerPresent(false)
|
|
expect(await checkPushSupport('key')).toBe('insecure-context')
|
|
})
|
|
|
|
it('insecure-context wins over vapid-missing', async () => {
|
|
setSecureContext(false)
|
|
expect(await checkPushSupport(null)).toBe('insecure-context')
|
|
})
|
|
|
|
it('unsupported wins over vapid-missing', async () => {
|
|
setPushManagerPresent(false)
|
|
expect(await checkPushSupport(null)).toBe('unsupported')
|
|
})
|
|
})
|
|
|
|
// ─────────────────────────── subscribePush ───────────────────────────────────
|
|
|
|
describe('subscribePush', () => {
|
|
it('returns null when Notification.requestPermission is denied', async () => {
|
|
vi.stubGlobal('Notification', {
|
|
permission: 'default',
|
|
requestPermission: vi.fn().mockResolvedValue('denied'),
|
|
})
|
|
mockFetch({}, 200)
|
|
const result = await subscribePush('some-key')
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('returns null when no SW registration is found', async () => {
|
|
vi.stubGlobal('Notification', {
|
|
permission: 'default',
|
|
requestPermission: vi.fn().mockResolvedValue('granted'),
|
|
})
|
|
setServiceWorker(null)
|
|
const result = await subscribePush('some-key')
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('posts subscription to /push/subscribe and returns it on success', async () => {
|
|
vi.stubGlobal('Notification', {
|
|
permission: 'default',
|
|
requestPermission: vi.fn().mockResolvedValue('granted'),
|
|
})
|
|
const mockSub = makeMockSubscription()
|
|
const reg = makeMockRegistration(null)
|
|
reg.pushManager.subscribe.mockResolvedValue(mockSub)
|
|
setServiceWorker(reg)
|
|
mockFetch({}, 201)
|
|
|
|
const result = await subscribePush('dGVzdA') // base64 "test"
|
|
expect(result).toBe(mockSub)
|
|
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
|
|
'/push/subscribe',
|
|
expect.objectContaining({ method: 'POST' }),
|
|
)
|
|
})
|
|
|
|
it('unsubscribes from browser and returns null when server rejects', async () => {
|
|
vi.stubGlobal('Notification', {
|
|
permission: 'default',
|
|
requestPermission: vi.fn().mockResolvedValue('granted'),
|
|
})
|
|
const mockSub = makeMockSubscription()
|
|
const reg = makeMockRegistration(null)
|
|
reg.pushManager.subscribe.mockResolvedValue(mockSub)
|
|
setServiceWorker(reg)
|
|
mockFetch({}, 400)
|
|
|
|
const result = await subscribePush('dGVzdA')
|
|
expect(result).toBeNull()
|
|
expect(mockSub.unsubscribe).toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns null on exception (e.g. pushManager.subscribe throws)', async () => {
|
|
vi.stubGlobal('Notification', {
|
|
permission: 'default',
|
|
requestPermission: vi.fn().mockResolvedValue('granted'),
|
|
})
|
|
const reg = makeMockRegistration(null)
|
|
reg.pushManager.subscribe.mockRejectedValue(new Error('blocked'))
|
|
setServiceWorker(reg)
|
|
|
|
const result = await subscribePush('dGVzdA')
|
|
expect(result).toBeNull()
|
|
})
|
|
})
|
|
|
|
// ─────────────────────────── unsubscribePush ─────────────────────────────────
|
|
|
|
describe('unsubscribePush', () => {
|
|
it('calls DELETE /push/subscribe and browser unsubscribe', async () => {
|
|
const mockSub = makeMockSubscription()
|
|
mockFetch({}, 204)
|
|
|
|
await unsubscribePush(mockSub as unknown as PushSubscription)
|
|
|
|
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
|
|
'/push/subscribe',
|
|
expect.objectContaining({ method: 'DELETE' }),
|
|
)
|
|
expect(mockSub.unsubscribe).toHaveBeenCalled()
|
|
})
|
|
|
|
it('still calls browser unsubscribe even when server DELETE fails', async () => {
|
|
const mockSub = makeMockSubscription()
|
|
mockFetchError()
|
|
|
|
await unsubscribePush(mockSub as unknown as PushSubscription)
|
|
expect(mockSub.unsubscribe).toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not throw when browser unsubscribe throws', async () => {
|
|
const mockSub = makeMockSubscription()
|
|
mockSub.unsubscribe.mockRejectedValue(new Error('browser error'))
|
|
mockFetch({}, 204)
|
|
|
|
await expect(
|
|
unsubscribePush(mockSub as unknown as PushSubscription),
|
|
).resolves.toBeUndefined()
|
|
})
|
|
})
|
|
|
|
// ─────────────────────────── mountPushToggle ─────────────────────────────────
|
|
|
|
describe('mountPushToggle — vapid-missing', () => {
|
|
it('hides the container when vapid key is missing (503)', async () => {
|
|
mockFetch({}, 503) // fetchVapidKey returns null
|
|
const container = document.createElement('div')
|
|
mountPushToggle(container)
|
|
|
|
// Wait for async init
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
expect(container.style.display).toBe('none')
|
|
expect(container.children.length).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('mountPushToggle — insecure-context (SEC-H8)', () => {
|
|
it('shows grayed bell with HTTPS/Tailscale hint, does not crash', async () => {
|
|
setSecureContext(false)
|
|
// fetchVapidKey might succeed, but checkPushSupport returns insecure-context
|
|
mockFetch({ publicKey: 'some-key' })
|
|
|
|
const container = document.createElement('div')
|
|
mountPushToggle(container)
|
|
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
// Container should NOT be hidden
|
|
expect(container.style.display).not.toBe('none')
|
|
// Should show a hint about HTTPS
|
|
const hint = container.querySelector('.push-hint')
|
|
expect(hint).not.toBeNull()
|
|
expect(hint?.textContent).toMatch(/https|tailscale/i)
|
|
})
|
|
})
|
|
|
|
describe('mountPushToggle — available', () => {
|
|
it('renders a functional toggle button', async () => {
|
|
mockFetch({ publicKey: 'some-key' })
|
|
// No existing subscription
|
|
|
|
const container = document.createElement('div')
|
|
mountPushToggle(container)
|
|
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
const btn = container.querySelector('button.push-toggle-btn')
|
|
expect(btn).not.toBeNull()
|
|
expect(btn?.getAttribute('aria-pressed')).toBe('false')
|
|
})
|
|
})
|
|
|
|
describe('mountPushToggle — subscribed', () => {
|
|
it('shows toggle as active (aria-pressed=true) when subscription exists', async () => {
|
|
mockFetch({ publicKey: 'some-key' })
|
|
setServiceWorker(makeMockRegistration(makeMockSubscription()))
|
|
|
|
const container = document.createElement('div')
|
|
mountPushToggle(container)
|
|
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
const btn = container.querySelector('button.push-toggle-btn')
|
|
expect(btn).not.toBeNull()
|
|
expect(btn?.getAttribute('aria-pressed')).toBe('true')
|
|
})
|
|
})
|
|
|
|
describe('mountPushToggle — permission-denied', () => {
|
|
it('shows grayed bell with blocked hint', async () => {
|
|
setNotification('denied')
|
|
mockFetch({ publicKey: 'some-key' })
|
|
|
|
const container = document.createElement('div')
|
|
mountPushToggle(container)
|
|
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
expect(container.style.display).not.toBe('none')
|
|
const hint = container.querySelector('.push-hint')
|
|
expect(hint).not.toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('mountPushToggle — onChange callback', () => {
|
|
it('calls onChange with the detected status', async () => {
|
|
mockFetch({}, 503)
|
|
const onChange = vi.fn()
|
|
const container = document.createElement('div')
|
|
mountPushToggle(container, { onChange })
|
|
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
expect(onChange).toHaveBeenCalledWith('vapid-missing')
|
|
})
|
|
})
|
|
|
|
describe('mountPushToggle — unsupported browser', () => {
|
|
it('shows grayed bell with unsupported hint when PushManager absent', async () => {
|
|
setPushManagerPresent(false)
|
|
mockFetch({ publicKey: 'some-key' })
|
|
|
|
const container = document.createElement('div')
|
|
mountPushToggle(container)
|
|
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
expect(container.style.display).not.toBe('none')
|
|
const hint = container.querySelector('.push-hint')
|
|
expect(hint).not.toBeNull()
|
|
})
|
|
})
|