Files
web-terminal/test/push.test.ts
Yaojia Wang d6809c65c4 feat(v0.7): Walk-away Workbench (Band A + B) — multi-agent parallel build
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.
2026-06-30 17:42:18 +02:00

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()
})
})