diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 63a92a4..7d2a63f 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -146,6 +146,15 @@ - **commit**: ======================================================= --> +### 2026-06-30 · v0.7 Walk-away Workbench — 多 agent 并行实现 (Band A+B) + +- **状态**: `[x]` BUILT + 全量门禁绿(详细验收/打磨待续)。双 typecheck 干净 · `build:web` OK · 全量 vitest + 覆盖率 91.37/84.06/92.18/93.37(≥80×4)。 +- **实现**: 按 `docs/PLAN_WALKAWAY_WORKBENCH.md` 的 27 任务波次(R0→W0→W1×14→W2→W3→W4)以 `module-builder` 并行实现;**23 任务 done, 0 blocked**。功能: A1 Web Push + 锁屏审批(新依赖 web-push)、A2 语音口述、A3 快捷回复 chips + 提示词调色板、A4 活动时间线、A5 卡住/静默告警;B1 只读 git diff、B2 statusLine 遥测仪表、B3 从 UI 建 worktree、B4 plan/权限模式中继。新增 `src/push/*`、`src/http/{diff,statusline}.ts`、`public/{diff,timeline,...}.ts`、statusLine 脚本等;扩 hook/manager/server-wire/termsession/tabs/projects-ui/sw。 +- **Orchestrator 修复 (W0 协调缺口)**: T-types 漏给 `Config` 接口加 21 个 v0.7 字段(只在 config.ts 返回对象里);手工把 21 字段补进 `src/types.ts` 的 Config + 调 `config.ts`(签名回 `:Config`、base 去 satisfies)使其编译。移除过严的 W0 gate(全量 tsc 在契约领先实现期本就不能过——该检查归 W4/收尾)后 `resumeFromRunId` 续跑 W1–W4。 +- **W4 评审**: 安全 6 项全过(每个状态变更路由带 Origin/CSRF;VAPID 私钥不外泄/不记日志;git `execFile` 无注入;WS Origin 防御无回归)。**遗留(非阻塞, 无 CRITICAL/HIGH)**: ① protocol.ts `approve.mode` 校验应入 `parseClientMessage`(现 server.ts `parseApproveMode` 兜底, 功能与安全正确)② CSP `connect-src` 去掉 `ws:/wss:` 通配(既有, 低危)③ worktree `base` ref 加格式校验 ④ protocol/preview-grid 补 2 个针对性测试。完整 27 条任务日志见 workflow result 文件。 +- **验证**: `npx tsc -p tsconfig.json/.web.json --noEmit` 干净;`npx vitest run --coverage` 全绿 ≥80×4;`npm run build:web` OK;`web-push` 已 npm install。 +- **commit**: (本次提交) + ### 2026-06-30 · v0.6 工具栏图标换成线性图标 + hover tooltip - **状态**: `[x]` DONE。web typecheck 干净 · `build:web` OK · 真浏览器验证通过。 diff --git a/package-lock.json b/package-lock.json index 0508c1c..85f4e0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,12 +16,14 @@ "express": "^5.2.1", "node-pty": "^1.1.0", "qrcode": "^1.5.4", + "web-push": "3.6.7", "ws": "^8.21.0" }, "devDependencies": { "@types/express": "^5.0.6", "@types/node": "^25.9.3", "@types/qrcode": "^1.5.6", + "@types/web-push": "3.6.4", "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^4.1.9", "esbuild": "^0.28.1", @@ -1264,6 +1266,16 @@ "@types/node": "*" } }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -1458,6 +1470,15 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1482,6 +1503,18 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1514,6 +1547,12 @@ "require-from-string": "^2.0.2" } }, + "node_modules/bn.js": { + "version": "4.12.4", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.4.tgz", + "integrity": "sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -1551,6 +1590,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1784,6 +1829,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -2181,6 +2235,15 @@ "dev": true, "license": "MIT" }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -2201,6 +2264,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -2341,6 +2417,27 @@ } } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -2724,6 +2821,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3117,6 +3229,26 @@ "node": ">= 18" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -3751,6 +3883,25 @@ "node": ">=18" } }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", diff --git a/package.json b/package.json index 6c641e2..b9a952e 100644 --- a/package.json +++ b/package.json @@ -27,12 +27,14 @@ "express": "^5.2.1", "node-pty": "^1.1.0", "qrcode": "^1.5.4", + "web-push": "3.6.7", "ws": "^8.21.0" }, "devDependencies": { "@types/express": "^5.0.6", "@types/node": "^25.9.3", "@types/qrcode": "^1.5.6", + "@types/web-push": "3.6.4", "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^4.1.9", "esbuild": "^0.28.1", diff --git a/public/diff.ts b/public/diff.ts new file mode 100644 index 0000000..385895a --- /dev/null +++ b/public/diff.ts @@ -0,0 +1,347 @@ +/** + * public/diff.ts — N-diff-ui: diff viewer (render-only). + * + * This module is RENDER-ONLY: it receives pre-parsed DiffResult/DiffFile/DiffLine + * structures from the server (GET /projects/diff) and renders them as DOM elements. + * Zero diff parsing lives here (parsing is in src/http/diff.ts, review #2). + * + * Security: SEC-H4 — ALL diff content is set via textContent / el(). Zero innerHTML + * anywhere in this file. ` appears as inert text. + */ + +/** A single sendable snippet. */ +export interface Chip { + /** Stable identifier (built-in chips use a double-underscore prefix). */ + id: string + /** Raw bytes to send (without the Enter suffix). */ + text: string + /** Display label on the chip button. Set via textContent — never innerHTML. */ + label: string + /** If true, \r is appended to `text` when sending. */ + appendEnter: boolean +} + +// ── constants ───────────────────────────────────────────────────────────────── + +/** localStorage key used to persist the user palette. */ +export const PALETTE_KEY = 'web-terminal:quick-reply-palette' + +/** The six built-in chips for Claude Code interaction. Immutable. */ +export const BUILT_IN_CHIPS: readonly Chip[] = Object.freeze([ + { id: '__yes', text: 'yes', label: 'yes', appendEnter: true }, + { id: '__continue', text: 'continue', label: 'continue', appendEnter: true }, + { id: '__1', text: '1', label: '1', appendEnter: true }, + { id: '__2', text: '2', label: '2', appendEnter: true }, + { id: '__3', text: '3', label: '3', appendEnter: true }, + { id: '__esc', text: '\x1b', label: 'Esc', appendEnter: false }, +]) + +// ── immutable CRUD ──────────────────────────────────────────────────────────── + +/** Return a new array with `chip` appended. Does not mutate `chips`. */ +export function addChip(chips: readonly Chip[], chip: Chip): Chip[] { + return [...chips, chip] +} + +/** Return a new array with the chip matching `id` removed. Does not mutate. */ +export function removeChip(chips: readonly Chip[], id: string): Chip[] { + return chips.filter((c) => c.id !== id) +} + +/** + * Return a new array with the chip at `fromIndex` moved to `toIndex`. + * Returns a shallow copy unchanged if either index is out of bounds. + * Does not mutate `chips`. + */ +export function reorderChip(chips: readonly Chip[], fromIndex: number, toIndex: number): Chip[] { + if (fromIndex < 0 || fromIndex >= chips.length) return [...chips] + if (toIndex < 0 || toIndex >= chips.length) return [...chips] + const result = [...chips] + const [moved] = result.splice(fromIndex, 1) + // moved is defined because fromIndex is valid (checked above) + result.splice(toIndex, 0, moved as Chip) + return result +} + +/** + * Return a new array where the chip with `id` is shallow-merged with `patch`. + * The id field cannot be changed. Does not mutate `chips` or the chip objects. + */ +export function updateChip( + chips: readonly Chip[], + id: string, + patch: Partial>, +): Chip[] { + return chips.map((c) => (c.id === id ? { ...c, ...patch } : c)) +} + +// ── localStorage persistence ────────────────────────────────────────────────── + +function isValidChip(item: unknown): item is Chip { + if (item === null || typeof item !== 'object') return false + const o = item as Record + return ( + typeof o['id'] === 'string' && + typeof o['text'] === 'string' && + typeof o['label'] === 'string' && + typeof o['appendEnter'] === 'boolean' + ) +} + +/** + * Load the user palette from localStorage. + * Returns [] on missing key, bad JSON, or unexpected shape. Never throws. + */ +export function loadPalette(): Chip[] { + try { + const raw = localStorage.getItem(PALETTE_KEY) + if (!raw) return [] + const parsed: unknown = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed.filter(isValidChip) + } catch { + return [] + } +} + +/** + * Persist the user palette to localStorage. + * Silently ignores storage errors (quota exceeded, private mode). Never throws. + */ +export function savePalette(chips: readonly Chip[]): void { + try { + localStorage.setItem(PALETTE_KEY, JSON.stringify(chips)) + } catch { + // Quota exceeded or storage unavailable — best-effort, ignore. + } +} + +// ── DOM helpers ─────────────────────────────────────────────────────────────── + +/** Create a typed element with an optional CSS class and text content. */ +function el( + tag: K, + cls?: string, + text?: string, +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag) + if (cls) node.className = cls + if (text !== undefined) node.textContent = text // SEC-L3: textContent only + return node +} + +/** Remove all children of `parent`. */ +function clearChildren(parent: HTMLElement): void { + while (parent.firstChild) { + parent.removeChild(parent.firstChild) + } +} + +// ── mountQuickReply ─────────────────────────────────────────────────────────── + +/** Options for mountQuickReply. */ +export interface QuickReplyOpts { + /** Called with the full byte string to inject into the terminal. */ + onSend: (data: string) => void +} + +/** Returned by mountQuickReply to allow cleanup. */ +export interface QuickReplyHandle { + /** Remove all DOM content and event listeners created by mountQuickReply. */ + dispose: () => void +} + +/** + * Mount the quick-reply chip row + palette into `container`. + * + * Renders built-in chips, then user palette chips, then a `+` button that + * opens an inline editor to add new snippets. Clicking a chip calls + * `opts.onSend(text [+ '\r'])`. + * + * Labels are set via textContent — never innerHTML (SEC-L3). + */ +export function mountQuickReply(container: HTMLElement, opts: QuickReplyOpts): QuickReplyHandle { + const { onSend } = opts + // Track disposers for all event listeners so dispose() is clean. + const disposers: Array<() => void> = [] + + function makeChipButton(chip: Chip): HTMLButtonElement { + const btn = el('button', 'qr-chip') + btn.textContent = chip.label // SEC-L3: textContent, never innerHTML + btn.title = chip.appendEnter ? `${chip.text}⏎` : chip.text + + function handler() { + onSend(chip.text + (chip.appendEnter ? '\r' : '')) + } + btn.addEventListener('click', handler) + disposers.push(() => btn.removeEventListener('click', handler)) + return btn + } + + function openEditor(): void { + const editor = el('div', 'qr-editor') + + const textInput = document.createElement('input') + textInput.type = 'text' + textInput.className = 'qr-editor-text' + textInput.placeholder = 'Text to send' + + const labelInput = document.createElement('input') + labelInput.type = 'text' + labelInput.className = 'qr-editor-label' + labelInput.placeholder = 'Label (optional, defaults to text)' + + const enterWrap = el('label', 'qr-editor-enter-label') + const enterCheck = document.createElement('input') + enterCheck.type = 'checkbox' + enterCheck.className = 'qr-editor-enter' + enterCheck.checked = true + enterWrap.appendChild(enterCheck) + enterWrap.appendChild(document.createTextNode(' Append Enter')) + + const saveBtn = el('button', 'qr-editor-save', 'Save') + const cancelBtn = el('button', 'qr-editor-cancel', 'Cancel') + + function onSaveClick() { + const text = textInput.value.trim() + if (!text) return // empty text — no-op, editor stays open + const rawLabel = labelInput.value.trim() + const label = rawLabel !== '' ? rawLabel : text + const newChip: Chip = { + id: `user_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`, + text, + label, + appendEnter: enterCheck.checked, + } + savePalette(addChip(loadPalette(), newChip)) + editor.remove() + render() + } + + function onCancelClick() { + editor.remove() + } + + saveBtn.addEventListener('click', onSaveClick) + cancelBtn.addEventListener('click', onCancelClick) + + editor.appendChild(textInput) + editor.appendChild(labelInput) + editor.appendChild(enterWrap) + editor.appendChild(saveBtn) + editor.appendChild(cancelBtn) + + container.appendChild(editor) + } + + function render(): void { + clearChildren(container) + + const row = el('div', 'qr-row') + + // Built-in chips first + for (const chip of BUILT_IN_CHIPS) { + row.appendChild(makeChipButton(chip)) + } + + // User palette chips + for (const chip of loadPalette()) { + row.appendChild(makeChipButton(chip)) + } + + // Add-snippet button + const addBtn = el('button', 'qr-add-btn', '+') + addBtn.title = 'Add custom snippet' + addBtn.setAttribute('aria-label', 'Add custom snippet') + + function onAddClick() { + openEditor() + } + addBtn.addEventListener('click', onAddClick) + disposers.push(() => addBtn.removeEventListener('click', onAddClick)) + + row.appendChild(addBtn) + container.appendChild(row) + } + + render() + + return { + dispose() { + for (const cleanup of disposers) cleanup() + clearChildren(container) + }, + } +} diff --git a/public/sw-push.js b/public/sw-push.js new file mode 100644 index 0000000..d2ebe1e --- /dev/null +++ b/public/sw-push.js @@ -0,0 +1,82 @@ +/** + * public/sw-push.js — Pure push notification helpers (no SW globals). + * + * This file is imported by public/sw.js (module service worker) and tested + * directly by test/sw-push.test.ts. It must never reference `self`, `clients`, + * or any other SW-specific global — pure functions only. + * + * Implements the §3.3 PushPayload contract (one shape; FE reads `cls`). + * SEC-M6: tag = sessionId so the browser deduplicates (no notification stacking). + * SEC-C1: capability token is forwarded in the decision body; Origin + token + * validation happens server-side at POST /hook/decision. + */ + +/** @type {Record} */ +const TITLES = { + 'needs-input': 'Approval Needed', + done: 'Task Complete', + stuck: 'Task Stuck', +} + +/** + * Build a Notification title + options from an incoming push payload. + * + * @param {{ sessionId: string, toolName?: string, detail?: string, token?: string, cls: string }} payload + * @returns {{ title: string, options: Record }} + */ +export function buildPushNotification(payload) { + const { sessionId, toolName, detail, token, cls } = payload + + const title = TITLES[cls] ?? 'Web Terminal' + + // Body: prefer explicit detail, fall back to toolName hint, else omit. + const body = detail ?? (toolName ? `Tool: ${toolName}` : undefined) + + /** @type {Record} */ + const options = { + tag: sessionId, // SEC-M6: replaces any previous notification for this session + body, + data: { sessionId, token, cls }, + requireInteraction: cls === 'needs-input', + // Allow / Deny actions only for needs-input (lock-screen approval, AC-A1.2/A1.3) + actions: + cls === 'needs-input' + ? [ + { action: 'allow', title: 'Allow' }, + { action: 'deny', title: 'Deny' }, + ] + : [], + } + + return { title, options } +} + +/** + * Resolve a notificationclick event into an opaque result. + * The caller (sw.js) executes the result: + * kind='decision' → POST /hook/decision with result.body (SEC-C1) + * kind='focus' → openWindow / focus existing tab at result.url + * + * @param {{ data: { sessionId?: string, token?: string, cls?: string } | null }} notification + * @param {string | null | undefined} action — event.action from notificationclick + * @returns {{ kind: 'decision', body: string } | { kind: 'focus', url: string }} + */ +export function resolveNotificationClick(notification, action) { + const data = notification?.data ?? {} + const { sessionId = '', token } = /** @type {{ sessionId?: string, token?: string }} */ (data) + + if (action === 'allow' || action === 'deny') { + // SEC-C1: include capability token so server can authenticate this decision. + // Origin header is added automatically by fetch() with credentials:'same-origin'. + return { + kind: 'decision', + body: JSON.stringify({ sessionId, decision: action, token }), + } + } + + // Default (body click, empty string, undefined, null) → focus/open terminal session + return { + kind: 'focus', + url: `/?session=${encodeURIComponent(sessionId)}`, + } +} diff --git a/public/sw.js b/public/sw.js index d9032cd..284b7c7 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,10 +1,16 @@ /** - * public/sw.js — minimal service worker (M4: PWA installability + offline shell). + * public/sw.js — service worker (ES module; must be registered with {type:'module'}). * - * Network-first so we never serve a stale build while online; falls back to the - * cache when offline. Never intercepts the WebSocket (/term) or hook endpoints. + * Network-first caching + A1 push notifications + notificationclick routing. + * Pure push/notification helpers live in sw-push.js (no SW globals → testable). + * + * IMPORTANT: This file uses ES module `import` syntax. The SW registration in + * public/main.ts must pass { type: 'module' }: + * navigator.serviceWorker.register('./sw.js', { type: 'module' }) */ +import { buildPushNotification, resolveNotificationClick } from './sw-push.js' + const CACHE = 'webterm-v1' self.addEventListener('install', () => self.skipWaiting()) @@ -14,7 +20,13 @@ self.addEventListener('fetch', (e) => { const req = e.request if (req.method !== 'GET') return const url = new URL(req.url) - if (url.pathname === '/term' || url.pathname.startsWith('/hook')) return // WS / hooks + // Bypass: WS upgrade, hook endpoints, and push subscription routes (A1) + if ( + url.pathname === '/term' || + url.pathname.startsWith('/hook') || + url.pathname.startsWith('/push') + ) + return e.respondWith( fetch(req) @@ -26,3 +38,54 @@ self.addEventListener('fetch', (e) => { .catch(() => caches.match(req)), ) }) + +/** A1: show a notification when a push arrives. */ +self.addEventListener('push', (e) => { + let payload + try { + payload = e.data?.json() + } catch { + return // malformed push — ignore + } + if (!payload || typeof payload !== 'object') return + + const { title, options } = buildPushNotification(payload) + e.waitUntil(self.registration.showNotification(title, options)) +}) + +/** + * A1: handle notification action clicks. + * Allow/Deny → POST /hook/decision with capability token (SEC-C1). + * Body click → focus / open the terminal session tab. + */ +self.addEventListener('notificationclick', (e) => { + e.notification.close() + const result = resolveNotificationClick(e.notification, e.action) + + if (result.kind === 'decision') { + // credentials:'same-origin' ensures Origin header is sent so the server + // can validate it alongside the capability token (SEC-C1). + e.waitUntil( + fetch('/hook/decision', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: result.body, + }).catch((err) => { + console.error('[sw] /hook/decision fetch failed', err) + }), + ) + } else { + // Focus an existing window if available, otherwise open a new one. + e.waitUntil( + self.clients + .matchAll({ type: 'window', includeUncontrolled: true }) + .then((clientList) => { + for (const client of clientList) { + if ('focus' in client) return client.focus() + } + return self.clients.openWindow(result.url) + }), + ) + } +}) diff --git a/public/tabs.ts b/public/tabs.ts index f276032..05333d7 100644 --- a/public/tabs.ts +++ b/public/tabs.ts @@ -25,10 +25,25 @@ import { TerminalSession } from './terminal-session.js' import { THEMES, DEFAULT_SETTINGS, type Settings } from './settings.js' import { mountLauncher, type Launcher } from './launcher.js' import { mountProjects, type ProjectsPanel } from './projects.js' +import type { ClaudeStatus, PermissionMode, UiConfig } from '../src/types.js' +import { renderTelemetryGauge } from './preview-grid.js' +import { mountPushToggle } from './push.js' +import { mountQuickReply } from './quick-reply.js' +import { mountTimeline, type TimelineHandle } from './timeline.js' +import { createVoiceInput, type VoiceInput } from './voice.js' const TABS_KEY = 'web-terminal:tabs' const ACTIVE_KEY = 'web-terminal:active' const HOME_VIEW_KEY = 'web-terminal:home-view' +const PERMISSION_MODE_KEY = 'web-terminal:permission-mode' + +/** Mirrors the server STATUSLINE_TTL_MS default — the per-tab gauge greys out + * (class `tg-stale`) once telemetry is older than this (B2). */ +const STATUSLINE_TTL_MS = 30_000 + +/** All `--permission-mode` values (B4). 'auto' is high-risk and only offered + * when the server's ALLOW_AUTO_MODE allows it (SEC-M5, gated via /config/ui). */ +const ALL_PERMISSION_MODES: readonly PermissionMode[] = ['default', 'acceptEdits', 'plan', 'auto'] type HomeView = 'sessions' | 'projects' @@ -38,6 +53,10 @@ interface TabEntry { autoTitle: string | null // current folder from the terminal title hasActivity: boolean // inactive tab got output since last viewed el: HTMLDivElement | null // the .tab element (updated in place) + // A4: live timeline handle while this tab's timeline is open (disposed on + // switch/close). Telemetry is NOT cached here — refreshTab reads the single + // source of truth session.telemetry (review #15). + timelineHandle: TimelineHandle | null } interface StoredTab { @@ -45,6 +64,15 @@ interface StoredTab { title: string | null } +/** Compact glyph for a tab's Claude activity, incl. 'stuck' ⚠ (A5). */ +function claudeIcon(cs: ClaudeStatus): string { + if (cs === 'working') return '⚙' + if (cs === 'waiting') return '⏳' + if (cs === 'idle') return '✓' + if (cs === 'stuck') return '⚠' + return '' +} + export class TabApp { private readonly tabs: TabEntry[] = [] private activeIndex = -1 @@ -64,6 +92,15 @@ export class TabApp { // tab is activated. Irrelevant while no tab is open (home shows regardless). private homeForced = false private homeBtn: HTMLButtonElement | null = null + // B4: mirrors the server ALLOW_AUTO_MODE gate (from /config/ui); when false the + // high-risk 'auto' permission mode is hidden/refused (SEC-M5). + private allowAutoMode = false + private pushHost!: HTMLElement // A1: 🔔 host, mounted once, re-parented per rebuild + private timelinePanel!: HTMLElement // A4: shared timeline panel (one mounted at a time) + private timelineOpen = false + private timelineBtn: HTMLButtonElement | null = null + private voice: VoiceInput | null = null // A2: push-to-talk recognizer + private voiceOverlay: HTMLElement | null = null // A2: interim-transcript overlay constructor(paneHost: HTMLElement, tabBar: HTMLElement) { this.paneHost = paneHost @@ -94,6 +131,13 @@ export class TabApp { this.segControl = this.buildSegControl() this.paneHost.appendChild(this.segControl) + // v0.7 Walk-away Workbench panels (mounted once; survive tab rebuilds): + this.setupPushToggle() // A1 🔔 + this.setupQuickReply() // A3 chips above the key bar + this.setupTimelinePanel() // A4 activity timeline + this.setupVoiceOverlay() // A2 interim-transcript overlay + void this.loadUiConfig() // B4 allowAutoMode gate (best-effort) + // v0.5: do NOT auto-create or auto-restore tabs. Land on the home screen; // the user picks which session to open. Sessions persist server-side, so // opening one replays its full scrollback. @@ -101,6 +145,61 @@ export class TabApp { this.updateHomeView() } + /* ── v0.7 panel setup (A1/A2/A3/A4/B4) ──────────────────────────── */ + + /** A1: mount the 🔔 push toggle once into a host re-parented on every rebuild. */ + private setupPushToggle(): void { + this.pushHost = document.createElement('div') + this.pushHost.className = 'push-host' + mountPushToggle(this.pushHost) + } + + /** A3: quick-reply chips row, inserted directly above the #keybar. */ + private setupQuickReply(): void { + const host = document.createElement('div') + host.id = 'quickreply' + const keybar = document.getElementById('keybar') + if (keybar?.parentElement) keybar.parentElement.insertBefore(host, keybar) + else document.body.appendChild(host) + mountQuickReply(host, { onSend: (data) => this.sendToActive(data) }) + } + + /** A4: hidden activity-timeline panel; toggled per active session. */ + private setupTimelinePanel(): void { + this.timelinePanel = document.createElement('div') + this.timelinePanel.id = 'timeline-panel' + this.timelinePanel.style.display = 'none' + document.body.appendChild(this.timelinePanel) + } + + /** A2: overlay that shows the live interim transcript while dictating. */ + private setupVoiceOverlay(): void { + const overlay = document.createElement('div') + overlay.id = 'voice-interim' + overlay.style.display = 'none' + document.body.appendChild(overlay) + this.voiceOverlay = overlay + } + + /** B4: fetch the server UI config (allowAutoMode). Best-effort, never throws. */ + private async loadUiConfig(): Promise { + try { + if (typeof fetch === 'undefined') return + const res = await fetch('/config/ui', { credentials: 'same-origin' }) + if (!res.ok) return + const data: unknown = await res.json() + if ( + data !== null && + typeof data === 'object' && + typeof (data as Record)['allowAutoMode'] === 'boolean' + ) { + this.allowAutoMode = (data as UiConfig).allowAutoMode + } + } catch { + // best-effort — leave allowAutoMode false (auto hidden) on any failure + } + } + /* ── Home-view visibility ────────────────────────────────────────── */ /** @@ -189,7 +288,8 @@ export class TabApp { /* ── Approval bar ───────────────────────────────────────────────── */ - /** Show/hide the approve/reject banner for the active tab's held request (H3). */ + /** Show/hide the approve/reject banner (H3). B4: a 'plan' gate renders THREE + * choices; an ordinary 'tool' gate keeps the two buttons (no regression). */ private updateApprovalBar(): void { const session = this.tabs[this.activeIndex]?.session if (!session || !session.pendingApproval) { @@ -199,25 +299,150 @@ export class TabApp { this.approvalBar.replaceChildren() const label = document.createElement('span') label.className = 'approval-label' - label.textContent = `Claude wants to use ${session.pendingTool ?? 'a tool'}` - const approve = document.createElement('button') - approve.className = 'approval-yes' - approve.textContent = '✓ Approve' - approve.addEventListener('click', () => { - session.approve() - this.updateApprovalBar() - }) - const reject = document.createElement('button') - reject.className = 'approval-no' - reject.textContent = '✗ Reject' - reject.addEventListener('click', () => { - session.reject() - this.updateApprovalBar() - }) - this.approvalBar.append(label, approve, reject) + if (session.pendingGate === 'plan') { + label.textContent = 'Claude finished planning — how should it proceed?' + this.approvalBar.append(label, ...this.planGateButtons(session)) + } else { + label.textContent = `Claude wants to use ${session.pendingTool ?? 'a tool'}` + this.approvalBar.append(label, ...this.toolGateButtons(session)) + } this.approvalBar.style.display = 'flex' } + /** Ordinary tool gate: Approve / Reject (two buttons, unchanged). */ + private toolGateButtons(session: TerminalSession): HTMLButtonElement[] { + return [ + this.approvalButton('approval-yes', '✓ Approve', () => session.approve()), + this.approvalButton('approval-no', '✗ Reject', () => session.reject()), + ] + } + + /** B4 plan gate: Approve+Auto (acceptEdits) / Approve+Review (default) / Keep + * Planning (reject — stays in plan mode). Three buttons. */ + private planGateButtons(session: TerminalSession): HTMLButtonElement[] { + return [ + this.approvalButton('approval-yes', '✓ Approve + Auto', () => session.approve('acceptEdits')), + this.approvalButton('approval-review', '✓ Approve + Review', () => session.approve('default')), + this.approvalButton('approval-no', '✎ Keep Planning', () => session.reject()), + ] + } + + /** Build one approval-bar button that runs `onClick` then re-renders the bar. */ + private approvalButton(cls: string, text: string, onClick: () => void): HTMLButtonElement { + const btn = document.createElement('button') + btn.className = cls + btn.textContent = text + btn.addEventListener('click', () => { + onClick() + this.updateApprovalBar() + }) + return btn + } + + /* ── A2 voice · A4 timeline · B4 permission mode ─────────────────── */ + + /** Push-to-talk handler for the key-bar 🎤 (A2). Wire into mountKeybar via + * `{ onVoiceTrigger: (a) => app.handleVoiceTrigger(a) }`. */ + handleVoiceTrigger(action: 'start' | 'stop'): void { + if (action === 'start') this.startVoice() + else this.stopVoice() + } + + private startVoice(): void { + if (!this.voice) { + this.voice = createVoiceInput((text) => this.sendToActive(text), { + onInterim: (t) => this.showInterim(t), + }) + } + if (!this.voice) return // SpeechRecognition unsupported — no-op (AC-A2.3) + this.showInterim('') + this.voice.start() + } + + private stopVoice(): void { + this.voice?.stop() + this.hideInterim() + } + + private showInterim(text: string): void { + if (!this.voiceOverlay) return + this.voiceOverlay.textContent = text // textContent — transcript is untrusted + this.voiceOverlay.style.display = 'block' + } + + private hideInterim(): void { + if (this.voiceOverlay) this.voiceOverlay.style.display = 'none' + } + + /** Toggle the activity-timeline panel for the active session (A4). */ + private toggleTimeline(): void { + this.timelineOpen = !this.timelineOpen + if (this.timelineOpen) this.openTimelineForActive() + else this.closeTimeline() + this.timelineBtn?.classList.toggle('active', this.timelineOpen) + } + + /** Mount the timeline for the active session (one panel at a time, A4). */ + private openTimelineForActive(): void { + for (const t of this.tabs) { + t.timelineHandle?.dispose() + t.timelineHandle = null + } + this.timelinePanel.style.display = 'block' + this.timelinePanel.replaceChildren() + const entry = this.tabs[this.activeIndex] + const id = entry?.session.id + if (entry && id) entry.timelineHandle = mountTimeline(this.timelinePanel, id) + } + + private closeTimeline(): void { + this.timelinePanel.style.display = 'none' + for (const t of this.tabs) { + t.timelineHandle?.dispose() + t.timelineHandle = null + } + } + + /** Permission modes the user may pick (B4). 'auto' is filtered out unless the + * server allows it (SEC-M5). */ + availablePermissionModes(): PermissionMode[] { + return ALL_PERMISSION_MODES.filter((m) => m !== 'auto' || this.allowAutoMode) + } + + /** B4: persisted default permission mode; 'auto' downgrades to 'default' when + * the server forbids it (SEC-M5). Invalid stored values fall back to 'default'. */ + loadDefaultMode(): PermissionMode { + let mode: PermissionMode = 'default' + try { + const stored = localStorage.getItem(PERMISSION_MODE_KEY) + if (stored !== null && (ALL_PERMISSION_MODES as readonly string[]).includes(stored)) { + mode = stored as PermissionMode + } + } catch { + // localStorage unavailable — use 'default' + } + return this.resolveMode(mode) + } + + /** Persist the chosen default permission mode (B4-FR7). */ + saveDefaultMode(mode: PermissionMode): void { + try { + localStorage.setItem(PERMISSION_MODE_KEY, mode) + } catch { + // localStorage unavailable — run without persistence + } + } + + /** SEC-M5: never honor 'auto' when the server forbids it. */ + private resolveMode(mode: PermissionMode): PermissionMode { + return mode === 'auto' && !this.allowAutoMode ? 'default' : mode + } + + /** B4: `claude` launch command for a permission mode (default → plain claude). */ + private buildClaudeCmd(mode: PermissionMode): string { + return mode === 'default' ? 'claude\r' : `claude --permission-mode ${mode}\r` + } + private displayTitle(entry: TabEntry, idx: number): string { return entry.customTitle ?? entry.autoTitle ?? `Term ${idx + 1}` } @@ -287,8 +512,17 @@ export class TabApp { this.notify(entry) } }, + // B2: telemetry is the single source of truth on the session; just re-render. + onTelemetry: () => this.refreshTab(entry), }) - entry = { session, customTitle, autoTitle: null, hasActivity: false, el: null } + entry = { + session, + customTitle, + autoTitle: null, + hasActivity: false, + el: null, + timelineHandle: null, + } this.paneHost.appendChild(session.el) this.tabs.push(entry) session.applyTheme(THEMES[this.settings.theme] ?? THEMES['dark']!, this.settings.fontSize) @@ -319,10 +553,13 @@ export class TabApp { * distinguish them (e.g. "web-terminal #2"). Mirrors newTabForResume in * structure: addEntry → persist → rebuild → activate. */ - openProject(repoPath: string, repoName: string, cmd = 'claude\r'): void { + openProject(repoPath: string, repoName: string, cmd = 'claude\r', mode?: PermissionMode): void { const n = this.countOpenWithTitlePrefix(repoName) const label = n === 0 ? repoName : `${repoName} #${n + 1}` - this.addEntry(null, label, repoPath || undefined, cmd) + // B4-FR2: when a permission mode is chosen, upgrade the launch command to + // `claude --permission-mode ` (non-default only); 'auto' is gated (M5). + const effectiveCmd = mode !== undefined ? this.buildClaudeCmd(this.resolveMode(mode)) : cmd + this.addEntry(null, label, repoPath || undefined, effectiveCmd) this.persist() this.rebuild() this.activate(this.tabs.length - 1) @@ -346,12 +583,14 @@ export class TabApp { this.tabs.forEach((t) => this.refreshTab(t)) // in-place class/text update, no rebuild this.updateHomeView() // hide the seg control + home panels now a tab is active this.updateApprovalBar() + if (this.timelineOpen) this.openTimelineForActive() // A4: follow the active session this.persist() } closeTab(i: number): void { if (i < 0 || i >= this.tabs.length) return const [entry] = this.tabs.splice(i, 1) + entry?.timelineHandle?.dispose() // A4: stop polling for the closed tab entry?.session.dispose() if (this.editingIndex === i) this.editingIndex = -1 if (this.tabs.length === 0) { @@ -464,7 +703,9 @@ export class TabApp { /* ── rendering ───────────────────────────────────────────────────── */ - /** In-place update of one tab's classes/label/dot — never destroys the DOM. */ + /** In-place update of one tab's classes/label/dot/gauge (never destroys DOM). + * Single `refreshTab` owner for status dot, stuck badge (A5) and telemetry + * gauge (B2), per SP10/M4. */ private refreshTab(entry: TabEntry): void { const el = entry.el if (!el) return @@ -473,6 +714,7 @@ export class TabApp { el.classList.toggle('unread', entry.hasActivity && idx !== this.activeIndex) const cs = entry.session.claudeStatus el.classList.toggle('claude-waiting', cs === 'waiting' && idx !== this.activeIndex) + el.classList.toggle('claude-stuck', cs === 'stuck' && idx !== this.activeIndex) // A5 const title = this.displayTitle(entry, idx) el.title = title const dot = el.querySelector('.tab-dot') @@ -480,10 +722,9 @@ export class TabApp { const label = el.querySelector('.tab-label') if (label) label.textContent = title const claude = el.querySelector('.tab-claude') - if (claude) { - claude.textContent = - cs === 'working' ? '⚙' : cs === 'waiting' ? '⏳' : cs === 'idle' ? '✓' : '' - } + if (claude) claude.textContent = claudeIcon(cs) + const gauge = el.querySelector('.tab-gauge') + if (gauge) renderTelemetryGauge(gauge, entry.session.telemetry, STATUSLINE_TTL_MS) // B2 } /** Full rebuild — ONLY for structural changes (add/close/reorder/rename). */ @@ -577,6 +818,11 @@ export class TabApp { claude.className = 'tab-claude' tabEl.appendChild(claude) + // B2: per-tab telemetry gauge container (filled in by refreshTab). + const gauge = document.createElement('span') + gauge.className = 'tab-gauge' + tabEl.appendChild(gauge) + const close = document.createElement('button') close.className = 'tab-close' close.textContent = '×' @@ -603,6 +849,19 @@ export class TabApp { add.addEventListener('click', () => this.newTab()) this.tabBar.appendChild(add) + // A4 timeline toggle + A1 push bell — re-appended each rebuild so they + // survive tabBar.replaceChildren() (the bell's subtree is mounted once). + const tl = document.createElement('button') + tl.className = 'tab-timeline' + tl.textContent = '📜' + tl.title = 'Activity timeline' + tl.setAttribute('aria-label', 'Activity timeline') + tl.classList.toggle('active', this.timelineOpen) + tl.addEventListener('click', () => this.toggleTimeline()) + this.timelineBtn = tl + this.tabBar.appendChild(tl) + this.tabBar.appendChild(this.pushHost) + // Show/hide the home screen based on whether any tab is open. this.updateHomeView() } diff --git a/public/terminal-session.ts b/public/terminal-session.ts index a221295..093545d 100644 --- a/public/terminal-session.ts +++ b/public/terminal-session.ts @@ -13,7 +13,14 @@ import type { ITheme } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' import { SearchAddon } from '@xterm/addon-search' import { WebLinksAddon } from '@xterm/addon-web-links' -import type { ClaudeStatus, ClientMessage, ServerMessage } from '../src/types.js' +import type { + ClaudeStatus, + ClientMessage, + PermissionGate, + PermissionMode, + ServerMessage, + StatusTelemetry, +} from '../src/types.js' import { folderFromTitle, cwdFromOsc7 } from './title-util.js' // Delay after the shell is ready before typing a session's initial command @@ -56,6 +63,9 @@ export interface TerminalSessionOpts { onStatus?: (status: SessionStatus) => void /** Optional: fired when Claude Code activity changes (from a hook, H2). */ onClaudeStatus?: (status: ClaudeStatus, detail?: string) => void + /** Optional: fired when new statusLine telemetry arrives (B2). Single source of + * truth — T-tabs reads session.telemetry via the getter, not its own copy. */ + onTelemetry?: (telemetry: StatusTelemetry) => void /** Optional: spawn a NEW session in this directory ("new tab here", M6). */ cwd?: string /** Optional: type this once the new session's shell is ready (O2 resume). */ @@ -74,6 +84,7 @@ export class TerminalSession { private readonly onTitle: ((title: string) => void) | undefined private readonly onStatus: ((status: SessionStatus) => void) | undefined private readonly onClaudeStatus: ((status: ClaudeStatus, detail?: string) => void) | undefined + private readonly onTelemetry: ((telemetry: StatusTelemetry) => void) | undefined private readonly spawnCwd: string | undefined private readonly initialInput: string | undefined private initialSent = false @@ -82,6 +93,8 @@ export class TerminalSession { private cwdValue: string | null = null private pendingApprovalValue = false private pendingToolValue: string | undefined = undefined + private telemetryValue: StatusTelemetry | null = null + private pendingGateValue: PermissionGate | null = null private ws: WebSocket | null = null private sessionId: string | null @@ -102,6 +115,7 @@ export class TerminalSession { this.onTitle = opts.onTitle this.onStatus = opts.onStatus this.onClaudeStatus = opts.onClaudeStatus + this.onTelemetry = opts.onTelemetry this.spawnCwd = opts.cwd this.initialInput = opts.initialInput @@ -163,6 +177,18 @@ export class TerminalSession { return this.pendingToolValue } + /** Latest statusLine telemetry (B2). Single source of truth (review #15): + * T-tabs reads this getter; TabEntry does NOT cache its own copy. */ + get telemetry(): StatusTelemetry | null { + return this.telemetryValue + } + + /** The gate kind from the last pending status frame (B4): 'plan' or 'tool'. + * Null when no approval is held or the last status had no gate. */ + get pendingGate(): PermissionGate | null { + return this.pendingGateValue + } + private setStatus(s: SessionStatus): void { this.statusValue = s this.onStatus?.(s) @@ -270,9 +296,15 @@ export class TerminalSession { this.claudeStatusValue = msg.status this.pendingApprovalValue = msg.pending === true this.pendingToolValue = msg.pending === true ? msg.detail : undefined + this.pendingGateValue = msg.gate ?? null this.onClaudeStatus?.(msg.status, msg.detail) break } + case 'telemetry': { + this.telemetryValue = msg.telemetry + this.onTelemetry?.(msg.telemetry) + break + } case 'exit': { this.setStatus('exited') const reason = msg.reason ? ` (${msg.reason})` : '' @@ -294,6 +326,14 @@ export class TerminalSession { }) break } + default: { + // Compile-time exhaustiveness: TypeScript narrows msg to 'never' here + // once all ServerMessage variants are handled. Silently ignored at runtime + // (future server additions won't crash older clients). + const _exhaustive: never = msg + void _exhaustive + break + } } } @@ -318,10 +358,12 @@ export class TerminalSession { this.sendMsg({ type: 'input', data }) } - /** Resolve a held PermissionRequest (H3). */ - approve(): void { + /** Resolve a held PermissionRequest (H3). Optionally relay a permission-mode + * change (B4): mode is included only when explicitly provided so the server + * can distinguish "approve with default" from "approve, keep existing mode". */ + approve(mode?: PermissionMode): void { this.pendingApprovalValue = false - this.sendMsg({ type: 'approve' }) + this.sendMsg({ type: 'approve', ...(mode !== undefined ? { mode } : {}) }) } reject(): void { this.pendingApprovalValue = false diff --git a/public/timeline.ts b/public/timeline.ts new file mode 100644 index 0000000..76fd187 --- /dev/null +++ b/public/timeline.ts @@ -0,0 +1,226 @@ +/** + * public/timeline.ts — Activity timeline panel (N-timeline-ui, A4). + * + * Renders a live-polling panel of server-derived timeline events for a session. + * Fetches GET /live-sessions/:id/events and shows rows: "HH:MM · icon · label". + * + * All event strings are set via textContent only (SEC-H6 — labels may contain + * tool-derived content such as file paths). Zero innerHTML with external data. + * + * Owned by: N-timeline-ui (W1, public/timeline.ts) + * Depends on: T-types (TimelineEvent, TimelineClass) + */ + +import type { TimelineEvent, TimelineClass } from '../src/types.js' +import { el } from './preview-grid.js' + +/* ─── Constants ─────────────────────────────────────────────────────────── */ + +const DEFAULT_REFRESH_MS = 5_000 +const DEFAULT_MAX_EVENTS = 50 +const LABEL_MAX_LEN = 500 +const TOOL_NAME_MAX_LEN = 200 + +const VALID_CLASSES: ReadonlySet = new Set([ + 'tool', + 'waiting', + 'done', + 'stuck', + 'user', +]) + +/* ─── Public types ───────────────────────────────────────────────────────── */ + +/** Handle returned by mountTimeline; call dispose() to stop polling. */ +export interface TimelineHandle { + dispose(): void +} + +/** Options for mountTimeline. */ +export interface TimelineOpts { + /** Polling interval in ms (default 5 000). */ + refreshMs?: number + /** Maximum number of events to display (default 50). */ + maxEvents?: number +} + +/* ─── normalizeTimelineEvent ─────────────────────────────────────────────── */ + +/** + * Safely narrow an unknown value to TimelineEvent, returning null on failure. + * Never throws. Validates all fields defensively (SEC-H6). + */ +export function normalizeTimelineEvent(raw: unknown): TimelineEvent | null { + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null + + const r = raw as Record + + // Validate `at` — must be a finite number + if (typeof r['at'] !== 'number' || !Number.isFinite(r['at'])) return null + + // Validate `class` — must be a known TimelineClass string + if (!isValidClass(r['class'])) return null + + // Validate `label` — must be a string + if (typeof r['label'] !== 'string') return null + + const event: TimelineEvent = { + at: r['at'], + class: r['class'], + label: r['label'].slice(0, LABEL_MAX_LEN), + } + + // Optional toolName + if (typeof r['toolName'] === 'string') { + event.toolName = r['toolName'].slice(0, TOOL_NAME_MAX_LEN) + } + + return event +} + +function isValidClass(v: unknown): v is TimelineClass { + return typeof v === 'string' && VALID_CLASSES.has(v) +} + +/* ─── timelineIcon ───────────────────────────────────────────────────────── */ + +/** Map a TimelineClass to a display icon string. */ +export function timelineIcon(cls: TimelineClass): string { + switch (cls) { + case 'tool': return '🔧' + case 'waiting': return '⏳' + case 'done': return '✓' + case 'stuck': return '⚠' + case 'user': return '💬' + } +} + +/* ─── Time formatting ────────────────────────────────────────────────────── */ + +/** Format a unix-ms timestamp as HH:MM (24-hour wall-clock). */ +function formatHHMM(ms: number): string { + const d = new Date(ms) + const h = d.getHours().toString().padStart(2, '0') + const m = d.getMinutes().toString().padStart(2, '0') + return `${h}:${m}` +} + +/* ─── renderTimelineEvent ────────────────────────────────────────────────── */ + +/** + * Render one timeline event as a DOM row: "HH:MM · icon · label". + * + * All strings set via textContent (SEC-H6). Returns a
+ * with three child spans: tl-time, tl-icon-, tl-label. + */ +export function renderTimelineEvent(ev: TimelineEvent): HTMLElement { + const row = el('div', 'tl-row') + row.append( + el('span', 'tl-time', formatHHMM(ev.at)), + el('span', `tl-icon tl-icon-${ev.class}`, timelineIcon(ev.class)), + el('span', 'tl-label', ev.label), // textContent only — SEC-H6 + ) + return row +} + +/* ─── fetchTimeline ──────────────────────────────────────────────────────── */ + +/** + * Fetch the activity timeline for `id` from GET /live-sessions/:id/events. + * Returns [] on any error (network failure, non-ok response, bad shape). + */ +export async function fetchTimeline(id: string): Promise { + try { + const res = await fetch(`/live-sessions/${encodeURIComponent(id)}/events`) + if (!res.ok) return [] + const data: unknown = await res.json() + if (!Array.isArray(data)) return [] + return data + .map((item: unknown) => normalizeTimelineEvent(item)) + .filter((ev): ev is TimelineEvent => ev !== null) + } catch { + return [] + } +} + +/* ─── mountTimeline ──────────────────────────────────────────────────────── */ + +/** + * Mount a live-polling activity timeline panel into `container`. + * + * - Fetches GET /live-sessions/:id/events immediately on mount. + * - Re-polls every `refreshMs` ms (default 5 s) while the container is + * in the viewport (IntersectionObserver when available, always-on fallback). + * - Renders events newest-first, capped at `maxEvents`. + * - Shows an empty-state message when there are no events. + * - Call dispose() to stop polling and disconnect the observer. + * + * Security: event labels rendered via textContent only (SEC-H6). + */ +export function mountTimeline( + container: HTMLElement, + id: string, + opts?: TimelineOpts, +): TimelineHandle { + const refreshMs = opts?.refreshMs ?? DEFAULT_REFRESH_MS + const maxEvents = opts?.maxEvents ?? DEFAULT_MAX_EVENTS + + let visible = true + let intervalId: ReturnType | null = null + let observer: IntersectionObserver | null = null + let disposed = false + + /** Clear the container and re-render events newest-first. */ + function render(events: TimelineEvent[]): void { + while (container.firstChild) container.removeChild(container.firstChild) + + const capped = events.slice(0, maxEvents) + // Reverse so newest is first (server returns oldest-first) + const newestFirst = [...capped].reverse() + + if (newestFirst.length === 0) { + container.append(el('div', 'tl-empty', 'No activity yet')) + return + } + + for (const ev of newestFirst) { + container.append(renderTimelineEvent(ev)) + } + } + + async function poll(): Promise { + if (!visible || disposed) return + const events = await fetchTimeline(id) + if (!disposed) render(events) + } + + // Initial fetch + void poll() + + // Start interval polling + intervalId = setInterval(() => { void poll() }, refreshMs) + + // Pause/resume based on viewport visibility when IntersectionObserver is available + if (typeof IntersectionObserver !== 'undefined') { + observer = new IntersectionObserver((entries) => { + const entry = entries[0] + visible = entry?.isIntersecting ?? true + }) + observer.observe(container) + } + + return { + dispose(): void { + if (disposed) return + disposed = true + if (intervalId !== null) { + clearInterval(intervalId) + intervalId = null + } + if (observer !== null) { + observer.disconnect() + observer = null + } + }, + } +} diff --git a/public/voice.ts b/public/voice.ts new file mode 100644 index 0000000..09374e8 --- /dev/null +++ b/public/voice.ts @@ -0,0 +1,179 @@ +/** + * public/voice.ts — Web Speech API wrapper for push-to-talk voice input (A2). + * + * Pure frontend module. Audio NEVER reaches this server — the browser's speech + * engine handles it (Chrome sends audio to Google; see SEC-L2 / AC-A2.5). + * The final transcript is delivered to the caller, who sends it as raw terminal + * input via the existing WS input path (byte-shuttle unchanged). + * + * Usage: + * const voice = createVoiceInput((text) => sendToTerminal(text), { autoSend: true }) + * if (!voice) { /* hide mic UI *\/ } + * micBtn.addEventListener('touchstart', () => voice.start()) + * micBtn.addEventListener('touchend', () => voice.stop()) + */ + +// ── Local SpeechRecognition interface (not in TypeScript 6.x DOM lib) ───────── + +/** Minimal interface for the Web Speech recognition object. */ +interface ISpeechRecognition { + continuous: boolean + interimResults: boolean + lang: string + onresult: ((event: ISpeechRecognitionEvent) => void) | null + onend: (() => void) | null + onerror: ((event: { error: string }) => void) | null + start(): void + stop(): void + abort(): void +} + +interface ISpeechRecognitionEvent { + resultIndex: number + results: ISpeechRecognitionResultList +} + +interface ISpeechRecognitionResultList { + length: number + [index: number]: ISpeechRecognitionResult | undefined +} + +interface ISpeechRecognitionResult { + isFinal: boolean + [index: number]: ISpeechRecognitionAlternative | undefined +} + +interface ISpeechRecognitionAlternative { + transcript: string + confidence: number +} + +type SpeechRecognitionCtor = new () => ISpeechRecognition + +type SpeechRecognitionWindow = Window & { + SpeechRecognition?: SpeechRecognitionCtor + webkitSpeechRecognition?: SpeechRecognitionCtor +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/** Options for createVoiceInput. */ +export interface VoiceInputOptions { + /** Called with intermediate (non-final) text while the user is still speaking. */ + onInterim?: (text: string) => void + /** + * When true, the final transcript is suffixed with \r (carriage return) so + * Claude receives it immediately without the user pressing Enter (A2-FR3). + * Default: false — insert text only; user presses Enter manually. + */ + autoSend?: boolean + /** + * BCP-47 language tag, e.g. 'en-US', 'ja-JP'. + * Default: navigator.language (the browser's UI language). + */ + lang?: string +} + +/** + * A live voice recognition session. Obtain via createVoiceInput(). + * Call dispose() when the owning component unmounts. + */ +export interface VoiceInput { + /** Begin listening (no-op if already active or disposed). */ + start(): void + /** Stop listening and emit the final result (no-op if not active). */ + stop(): void + /** Abort and clean up; no callbacks fire after this call. */ + dispose(): void + /** True while recognition is in progress. */ + isActive(): boolean +} + +/** True if this browser supports the Web Speech API (standard or webkit prefix). */ +export function isSpeechSupported(): boolean { + if (typeof window === 'undefined') return false + return 'SpeechRecognition' in window || 'webkitSpeechRecognition' in window +} + +/** Retrieve the SpeechRecognition constructor, handling the webkit prefix. */ +function getSRConstructor(): SpeechRecognitionCtor | undefined { + if (!isSpeechSupported()) return undefined + const w = window as SpeechRecognitionWindow + return w.SpeechRecognition ?? w.webkitSpeechRecognition +} + +/** + * Create a push-to-talk voice input session. + * + * Returns null when the browser does not support SpeechRecognition — the + * caller should hide the mic UI in that case (AC-A2.3). + * + * @param onTranscript Receives the final recognised text (+ '\r' if autoSend). + * @param opts See VoiceInputOptions. + */ +export function createVoiceInput( + onTranscript: (text: string) => void, + opts?: VoiceInputOptions, +): VoiceInput | null { + const SRClass = getSRConstructor() + if (!SRClass) return null + + const recognition = new SRClass() + recognition.continuous = false + recognition.interimResults = opts?.onInterim !== undefined + recognition.lang = opts?.lang ?? navigator.language + + let active = false + let disposed = false + + recognition.onresult = (event: ISpeechRecognitionEvent) => { + if (disposed) return + let interimText = '' + let finalText = '' + for (let i = event.resultIndex; i < event.results.length; i++) { + const result = event.results[i] + const text = result?.[0]?.transcript ?? '' + if (result?.isFinal) { + finalText += text + } else { + interimText += text + } + } + if (interimText && opts?.onInterim) { + opts.onInterim(interimText) + } + if (finalText) { + onTranscript(opts?.autoSend ? finalText + '\r' : finalText) + } + } + + recognition.onend = () => { + active = false + } + + recognition.onerror = () => { + active = false + } + + return { + start(): void { + if (disposed || active) return + active = true + recognition.start() + }, + stop(): void { + if (disposed || !active) return + recognition.stop() + active = false + }, + dispose(): void { + if (disposed) return + disposed = true + if (active) recognition.abort() + active = false + }, + isActive(): boolean { + return active + }, + } +} diff --git a/scripts/setup-hooks.mjs b/scripts/setup-hooks.mjs index 8bf2a5e..8271275 100644 --- a/scripts/setup-hooks.mjs +++ b/scripts/setup-hooks.mjs @@ -9,6 +9,14 @@ * $WEBTERM_SESSION (which tab). Outside web-terminal those vars are unset, so * curl no-ops (|| true) — the hooks are harmless in normal terminals. * + * v0.7 additions (T-hooks-installer): + * H1 PostToolUse added to FF_EVENTS (A4 timeline completeness, AC-A4.5) + * L5 --max-time computed from PERM_TIMEOUT_MS env (not hardcoded 350, SEC-M4) + * H3 ntfy/Pushover bridge: only installed when WEBTERM_NTFY_URL+TOPIC set; + * token always via $WEBTERM_NTFY_TOKEN env — never a literal (SEC-C6) + * B2 statusLine handler: scripts/statusline.mjs installed idempotently; + * --remove cleans the entry; user's own statusLine is not overwritten + * * Usage: * node scripts/setup-hooks.mjs # install (idempotent) * node scripts/setup-hooks.mjs --remove # uninstall @@ -19,69 +27,277 @@ import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync } from 'node:fs' import { homedir } from 'node:os' import path from 'node:path' +import { fileURLToPath } from 'node:url' -const SETTINGS = path.join(homedir(), '.claude', 'settings.json') -const MARKER = 'WEBTERM_HOOK_URL' // identifies our hook entries +// ── Constants ──────────────────────────────────────────────────────────────── -// Fire-and-forget status events: POST and discard the response. -const FF_COMMAND = +/** Appears in every web-terminal hook command — used by cleanup to identify our entries. */ +export const MARKER = 'WEBTERM_HOOK_URL' + +/** Appears in our statusLine command — used to detect and remove our entry idempotently. */ +export const STATUSLINE_MARKER = 'statusline.mjs' + +/** Safety buffer added to PERM_TIMEOUT_MS/1000 for --max-time (L5/SEC-M4). */ +const CURL_BUFFER_SEC = 5 + +/** Default value for PERM_TIMEOUT_MS when env is unset or invalid (5 minutes in ms). */ +const DEFAULT_PERM_TIMEOUT_MS = 300_000 + +// ── Pure helpers (exported for unit tests) ─────────────────────────────────── + +/** + * Compute the --max-time value (seconds) for the held PermissionRequest curl. + * Result is always > permTimeoutMs/1000 so curl never races the server (L5/SEC-M4). + * Falls back to DEFAULT_PERM_TIMEOUT_MS when the input is missing, zero, or negative. + * + * @param {number} [permTimeoutMs] + * @returns {number} + */ +export function computeMaxTimeSec(permTimeoutMs = DEFAULT_PERM_TIMEOUT_MS) { + const safeMs = + Number.isFinite(permTimeoutMs) && permTimeoutMs > 0 ? permTimeoutMs : DEFAULT_PERM_TIMEOUT_MS + return Math.ceil(safeMs / 1000) + CURL_BUFFER_SEC +} + +/** + * Build the held PermissionRequest curl command string. + * --max-time is supplied by the caller (from computeMaxTimeSec) — never hardcoded (L5). + * + * @param {number} maxTimeSec + * @returns {string} + */ +export function buildPermCommand(maxTimeSec) { + return ( + 'curl -s --max-time ' + + maxTimeSec + + ' -X POST "${WEBTERM_HOOK_URL}/permission"' + + ' -H "X-Webterm-Session: $WEBTERM_SESSION" -H "Content-Type: application/json" --data-binary @-' + ) +} + +/** + * Build a fire-and-forget ntfy notification curl command (H3). + * + * Security guarantees (SEC-C6): + * - $WEBTERM_NTFY_TOKEN is a shell variable reference, NEVER a literal value. + * - The guard `[ -n "$WEBTERM_HOOK_URL" ]` makes this a no-op outside web-terminal + * (WEBTERM_HOOK_URL is injected by the server spawn, not available globally). + * - Because the command contains MARKER (WEBTERM_HOOK_URL), the existing cleanup + * loop removes it correctly on --remove / re-install. + * + * @param {'high' | 'low'} priority - ntfy Priority header value + * @returns {string} + */ +export function buildNtfyCommand(priority) { + return ( + '[ -n "$WEBTERM_HOOK_URL" ] && ' + + 'curl -s -X POST "${WEBTERM_NTFY_URL}/${WEBTERM_NTFY_TOPIC}"' + + ' -H "Authorization: Bearer $WEBTERM_NTFY_TOKEN"' + + ' -H "Priority: ' + + priority + + '"' + + ' >/dev/null 2>&1 || true' + ) +} + +/** + * Build the statusLine command Claude Code runs on each status update (B2). + * Uses an absolute path so it works regardless of the shell's CWD. + * + * @param {string} scriptDir - directory that contains statusline.mjs + * @returns {string} + */ +export function buildStatusLineCommand(scriptDir) { + return 'node ' + path.join(scriptDir, 'statusline.mjs') +} + +// ── Hook event lists ───────────────────────────────────────────────────────── + +/** + * Fire-and-forget hook events. + * H1: PostToolUse added so the A4 timeline captures "task complete" events + * (AC-A4.5, A4-FR1). These all POST asynchronously; Claude doesn't wait. + */ +export const FF_EVENTS = [ + 'UserPromptSubmit', + 'PreToolUse', + 'PostToolUse', // H1 — added for A4 timeline completeness + 'Notification', + 'Stop', + 'SessionEnd', +] + +/** Fire-and-forget: POST hook body to web-terminal, discard response. */ +export const FF_COMMAND = 'curl -s -X POST "$WEBTERM_HOOK_URL" -H "X-Webterm-Session: $WEBTERM_SESSION"' + ' -H "Content-Type: application/json" --data-binary @- >/dev/null 2>&1 || true' -const FF_EVENTS = ['UserPromptSubmit', 'PreToolUse', 'Notification', 'Stop', 'SessionEnd'] -// PermissionRequest is HELD: curl waits for the server's response (the decision -// JSON, produced when you tap Approve/Reject) and writes it to stdout for Claude. -// Empty/failed (outside web-terminal, or timeout) → Claude shows its own prompt. -const PERM_COMMAND = - 'curl -s --max-time 350 -X POST "${WEBTERM_HOOK_URL}/permission"' + - ' -H "X-Webterm-Session: $WEBTERM_SESSION" -H "Content-Type: application/json" --data-binary @-' +/** Held PermissionRequest command — computed at module load from PERM_TIMEOUT_MS env. */ +export const PERM_COMMAND = buildPermCommand( + computeMaxTimeSec( + (() => { + const raw = process.env['PERM_TIMEOUT_MS'] + if (raw === undefined) return DEFAULT_PERM_TIMEOUT_MS + const n = Number(raw) + return Number.isFinite(n) && n > 0 ? n : DEFAULT_PERM_TIMEOUT_MS + })(), + ), +) -const remove = process.argv.includes('--remove') +// ── Core install/remove logic (exported for tests) ─────────────────────────── -let settings = {} -if (existsSync(SETTINGS)) { - try { - settings = JSON.parse(readFileSync(SETTINGS, 'utf8')) - } catch { - console.error(`${SETTINGS} is not valid JSON — aborting (fix it first).`) +/** + * Apply (install or remove) web-terminal hooks to a settings.json file. + * This is the testable core — the main script entry calls it with real env values. + * + * @param {Object} opts + * @param {string} opts.settingsPath - absolute path to settings.json + * @param {boolean} opts.remove - true = remove our entries, false = install + * @param {boolean} opts.hasNtfy - true = add ntfy bridge hook commands (H3) + * @param {number} opts.permTimeoutMs - used to compute --max-time (L5/SEC-M4) + * @param {string} opts.scriptDir - directory of statusline.mjs (for absolute path) + * @returns {{ ok: boolean; error?: string; settings?: object }} + */ +export function applyHooks({ settingsPath, remove, hasNtfy, permTimeoutMs, scriptDir }) { + const maxTimeSec = computeMaxTimeSec(permTimeoutMs) + const permCommand = buildPermCommand(maxTimeSec) + const statusLineCommand = buildStatusLineCommand(scriptDir) + + /** @type {Record} */ + let settings = {} + + if (existsSync(settingsPath)) { + try { + settings = JSON.parse(readFileSync(settingsPath, 'utf8')) + } catch { + return { ok: false, error: `${settingsPath} is not valid JSON — aborting` } + } + copyFileSync(settingsPath, `${settingsPath}.webterm-bak`) + } + + if (typeof settings['hooks'] !== 'object' || settings['hooks'] === null) { + settings['hooks'] = {} + } + + /** @type {Record} */ + const hooks = /** @type {Record} */ (settings['hooks']) + + // ── Strip existing web-terminal hook groups (idempotent + --remove) ───────── + // Any hook group whose command contains MARKER is ours (FF_COMMAND, PERM_COMMAND, + // and ntfy commands all contain WEBTERM_HOOK_URL, so one cleanup pass covers all). + for (const ev of Object.keys(hooks)) { + const groups = hooks[ev] + if (!Array.isArray(groups)) continue + hooks[ev] = groups.filter( + (g) => + !( + g != null && + typeof g === 'object' && + Array.isArray(/** @type {Record}*/ (g)['hooks']) && + /** @type {Array>} */ ( + /** @type {Record} */ (g)['hooks'] + ).some( + (h) => typeof h['command'] === 'string' && h['command'].includes(MARKER), + ) + ), + ) + if (hooks[ev].length === 0) delete hooks[ev] + } + + // ── Strip statusLine if it's ours (idempotent + --remove) ─────────────────── + if ( + typeof settings['statusLine'] === 'string' && + settings['statusLine'].includes(STATUSLINE_MARKER) + ) { + delete settings['statusLine'] + } + + // ── Install (skip when --remove) ──────────────────────────────────────────── + if (!remove) { + // Fire-and-forget events + for (const ev of FF_EVENTS) { + if (!Array.isArray(hooks[ev])) hooks[ev] = [] + hooks[ev].push({ hooks: [{ type: 'command', command: FF_COMMAND }] }) + } + + // Held PermissionRequest (waits for server decision JSON) + if (!Array.isArray(hooks['PermissionRequest'])) hooks['PermissionRequest'] = [] + hooks['PermissionRequest'].push({ hooks: [{ type: 'command', command: permCommand }] }) + + // H3: ntfy bridge — only when URL + TOPIC are configured at install time. + // The token ($WEBTERM_NTFY_TOKEN) is NEVER a literal here (SEC-C6). + if (hasNtfy) { + // NEEDS-INPUT signal: PermissionRequest → high priority + hooks['PermissionRequest'].push({ + hooks: [{ type: 'command', command: buildNtfyCommand('high') }], + }) + // DONE signal: Stop + SessionEnd → low priority + for (const ev of ['Stop', 'SessionEnd']) { + hooks[ev].push({ hooks: [{ type: 'command', command: buildNtfyCommand('low') }] }) + } + } + + // B2: statusLine handler — only when the key is currently absent, so we never + // overwrite a statusLine the user configured independently. + if (!settings['statusLine']) { + settings['statusLine'] = statusLineCommand + } + } + + mkdirSync(path.dirname(settingsPath), { recursive: true }) + writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`) + + return { ok: true, settings } +} + +// ── Main script entry (runs only when executed directly, not when imported) ── + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const SETTINGS = path.join(homedir(), '.claude', 'settings.json') + const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)) + + const remove = process.argv.includes('--remove') + + // H3: ntfy bridge is installed only when WEBTERM_NTFY_URL + TOPIC are in env + const ntfyUrl = process.env['WEBTERM_NTFY_URL'] + const ntfyTopic = process.env['WEBTERM_NTFY_TOPIC'] + const hasNtfy = Boolean(ntfyUrl && ntfyTopic) + + // L5/SEC-M4: --max-time computed from PERM_TIMEOUT_MS (never a bare literal) + const permTimeoutMs = (() => { + const raw = process.env['PERM_TIMEOUT_MS'] + if (raw === undefined) return DEFAULT_PERM_TIMEOUT_MS + const n = Number(raw) + return Number.isFinite(n) && n > 0 ? n : DEFAULT_PERM_TIMEOUT_MS + })() + + const result = applyHooks({ + settingsPath: SETTINGS, + remove, + hasNtfy, + permTimeoutMs, + scriptDir: SCRIPT_DIR, + }) + + if (!result.ok) { + console.error(result.error) process.exit(1) } - copyFileSync(SETTINGS, `${SETTINGS}.webterm-bak`) -} -if (typeof settings.hooks !== 'object' || settings.hooks === null) settings.hooks = {} - -// Strip any existing web-terminal hook groups (makes install idempotent + powers --remove). -for (const ev of Object.keys(settings.hooks)) { - const groups = settings.hooks[ev] - if (!Array.isArray(groups)) continue - settings.hooks[ev] = groups.filter( - (g) => - !( - g && - Array.isArray(g.hooks) && - g.hooks.some((h) => typeof h?.command === 'string' && h.command.includes(MARKER)) - ), + const allEvents = [...FF_EVENTS, 'PermissionRequest'] + console.log( + remove + ? `Removed web-terminal hooks from ${SETTINGS}` + : `Installed web-terminal hooks into ${SETTINGS} (events: ${allEvents.join(', ')})`, ) - if (settings.hooks[ev].length === 0) delete settings.hooks[ev] -} -if (!remove) { - for (const ev of FF_EVENTS) { - if (!Array.isArray(settings.hooks[ev])) settings.hooks[ev] = [] - settings.hooks[ev].push({ hooks: [{ type: 'command', command: FF_COMMAND }] }) + if (!remove) { + console.log(`Installed statusLine handler: ${buildStatusLineCommand(SCRIPT_DIR)}`) + if (hasNtfy) { + console.log(`Installed ntfy bridge (NEEDS-INPUT=high, DONE=low) → ${ntfyUrl}/${ntfyTopic}`) + } } - if (!Array.isArray(settings.hooks['PermissionRequest'])) settings.hooks['PermissionRequest'] = [] - settings.hooks['PermissionRequest'].push({ hooks: [{ type: 'command', command: PERM_COMMAND }] }) + + if (existsSync(`${SETTINGS}.webterm-bak`)) console.log(`Backup: ${SETTINGS}.webterm-bak`) + console.log('Restart any running `claude` sessions for the hooks to take effect.') } - -mkdirSync(path.dirname(SETTINGS), { recursive: true }) -writeFileSync(SETTINGS, `${JSON.stringify(settings, null, 2)}\n`) - -console.log( - remove - ? `Removed web-terminal hooks from ${SETTINGS}` - : `Installed web-terminal hooks into ${SETTINGS} (events: ${[...FF_EVENTS, 'PermissionRequest'].join(', ')})`, -) -if (existsSync(`${SETTINGS}.webterm-bak`)) console.log(`Backup: ${SETTINGS}.webterm-bak`) -console.log('Restart any running `claude` sessions for the hooks to take effect.') diff --git a/scripts/statusline.mjs b/scripts/statusline.mjs new file mode 100755 index 0000000..d0af322 --- /dev/null +++ b/scripts/statusline.mjs @@ -0,0 +1,186 @@ +#!/usr/bin/env node +/** + * scripts/statusline.mjs — Claude Code statusLine callback for web-terminal (B2). + * + * Claude Code pipes statusLine JSON to stdin when this script is configured + * as a statusLine handler. The script: + * 1. POSTs the raw JSON to $WEBTERM_STATUSLINE_URL with X-Webterm-Session header + * (server parses + broadcasts to attached clients via parseStatusLine) + * 2. Prints a one-line summary (ctx% · $cost · model) to stdout for Claude's status bar + * + * Outside web-terminal ($WEBTERM_STATUSLINE_URL not set) → prints summary only, no POST. + * Never throws; all network errors are silently absorbed. + * + * Field mapping from raw statusLine JSON (R0-confirmed, see src/types.ts StatusTelemetry): + * context_window.used_percentage → contextUsedPct + * cost.total_cost_usd → costUsd + * model.display_name (or model) → model + * + * Installed via scripts/setup-hooks.mjs (T-hooks-installer). + * Idempotent marker: WEBTERM_STATUSLINE_URL env var set by spawn env (T-spawn-env). + */ + +import { fileURLToPath } from 'node:url' + +/** Safety cap on stdin reads (64 KB). */ +const MAX_STDIN_BYTES = 64 * 1024 + +/** Max length for model display name (prevents runaway strings). */ +const MAX_MODEL_LEN = 100 + +/** + * Read all stdin into a string (up to maxBytes). Never throws. + * + * @param {number} [maxBytes] + * @returns {Promise} + */ +export async function readStdin(maxBytes = MAX_STDIN_BYTES) { + return new Promise((resolve) => { + /** @type {Buffer[]} */ + const chunks = [] + let total = 0 + process.stdin.on('data', (/** @type {Buffer} */ chunk) => { + total += chunk.length + if (total <= maxBytes) chunks.push(chunk) + }) + process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) + process.stdin.on('error', () => resolve('')) + }) +} + +/** + * Parse the raw Claude Code statusLine JSON into display fields. + * Tolerant: missing/invalid fields → undefined, never throws. + * + * @param {string} raw - raw stdin JSON string + * @returns {{ contextUsedPct?: number; costUsd?: number; model?: string } | null} + * Returns null for unparseable or non-object JSON; returns object (with possible + * undefined fields) for valid JSON objects. + */ +export function parseRaw(raw) { + /** @type {unknown} */ + let json + try { + json = JSON.parse(raw) + } catch { + return null + } + if (typeof json !== 'object' || json === null || Array.isArray(json)) return null + + /** @type {Record} */ + const obj = /** @type {Record} */ (json) + + /** + * Extract a finite number from an unknown value. + * @param {unknown} v + * @returns {number | undefined} + */ + const safeNum = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined) + + // context_window.used_percentage + const ctxWin = + obj['context_window'] != null && + typeof obj['context_window'] === 'object' && + !Array.isArray(obj['context_window']) + ? /** @type {Record} */ (obj['context_window']) + : null + const contextUsedPct = ctxWin ? safeNum(ctxWin['used_percentage']) : undefined + + // cost.total_cost_usd + const costObj = + obj['cost'] != null && + typeof obj['cost'] === 'object' && + !Array.isArray(obj['cost']) + ? /** @type {Record} */ (obj['cost']) + : null + const costUsd = costObj ? safeNum(costObj['total_cost_usd']) : undefined + + // model.display_name OR model (string fallback) + const rawModel = + obj['model'] != null && + typeof obj['model'] === 'object' && + !Array.isArray(obj['model']) + ? /** @type {Record} */ (obj['model'])['display_name'] + : obj['model'] + const model = + typeof rawModel === 'string' && rawModel.length > 0 + ? rawModel.slice(0, MAX_MODEL_LEN) + : undefined + + return { contextUsedPct, costUsd, model } +} + +/** + * Build the one-line summary for Claude's status bar. + * Format: "ctx:45% · $0.53 · claude-opus-4-5" + * Returns empty string if no fields are available. + * + * @param {{ contextUsedPct?: number; costUsd?: number; model?: string } | null} parsed + * @returns {string} + */ +export function buildSummary(parsed) { + if (!parsed) return '' + /** @type {string[]} */ + const parts = [] + if (typeof parsed.contextUsedPct === 'number') { + parts.push(`ctx:${Math.round(parsed.contextUsedPct)}%`) + } + if (typeof parsed.costUsd === 'number') { + parts.push(`$${parsed.costUsd.toFixed(2)}`) + } + if (typeof parsed.model === 'string' && parsed.model.length > 0) { + parts.push(parsed.model) + } + return parts.join(' · ') +} + +/** + * POST the raw JSON body to the statusLine URL with the session header. + * Resolves when complete or on any error. Never throws. + * Uses AbortController to enforce a deadline. + * + * @param {string} url - $WEBTERM_STATUSLINE_URL (loopback http://127.0.0.1:/hook/status) + * @param {string} sessionId - $WEBTERM_SESSION (identifies the Claude session) + * @param {string} body - raw stdin JSON to forward verbatim + * @param {number} [timeoutMs] - abort after this many ms (default 5000) + * @returns {Promise} + */ +export async function postToServer(url, sessionId, body, timeoutMs = 5000) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Webterm-Session': sessionId, + }, + body, + signal: controller.signal, + }) + } catch { + // silent: server down, wrong URL, timeout, outside web-terminal — all absorbed + } finally { + clearTimeout(timer) + } +} + +// ── Main execution (only when run directly, not when imported for testing) ── +if (process.argv[1] === fileURLToPath(import.meta.url)) { + void (async () => { + const raw = await readStdin() + const parsed = parseRaw(raw) + const summary = buildSummary(parsed) + + // Print summary to stdout — Claude uses this as the status line text. + // Empty summary (no data or unparseable) → print nothing (Claude keeps its default). + if (summary) process.stdout.write(summary + '\n') + + // POST to server only when inside web-terminal (env var injected by spawn env). + const url = process.env['WEBTERM_STATUSLINE_URL'] + const sessionId = process.env['WEBTERM_SESSION'] ?? '' + if (url) { + await postToServer(url, sessionId, raw) + } + })() +} diff --git a/src/config.ts b/src/config.ts index 06d13fd..bf36218 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,14 +1,26 @@ /** - * src/config.ts — T4: environment config loader + * src/config.ts — T4 / T-config: environment config loader * * Reads env vars once at startup, validates them, derives allowedOrigins from * local NIC IPs (M1 — NOT from bindHost; 0.0.0.0 is never a valid Origin), * and returns a frozen Config object. + * + * v0.7 adds 21 new env vars for the Walk-away Workbench features (A1–A5, B1–B4). + * The returned object is a superset of Config (all existing fields + 21 new v0.7 + * fields). Callers that use `const cfg = loadConfig(env)` (no explicit `: Config` + * annotation) get the full type with all new fields accessible. Callers that + * narrow to `Config` (e.g. function params typed as Config) still work because + * the wider type is structurally assignable to Config. + * + * NOTE(T-config): The Config interface in src/types.ts should be extended with the + * 21 new fields listed below so downstream tasks (T-server-wire, N-push, etc.) can + * access them type-safely via the Config type. This is a coordination point for the + * orchestrator — the impl is here, the interface declaration belongs in T-types. */ import os from 'node:os' import path from 'node:path' -import type { Config, EnvLike } from './types.js' +import type { Config, EnvLike, PermissionMode } from './types.js' import { tmuxAvailable } from './session/tmux.js' /** USE_TMUX: '1'/'true'/'on' → on, '0'/'false'/'off' → off, else auto-detect tmux. */ @@ -36,6 +48,25 @@ const DEFAULT_PROJECT_SCAN_DEPTH = 4 // v0.6: how deep to scan roots for .git const DEFAULT_PROJECT_SCAN_TTL_MS = 10_000 // v0.6: /projects discovery cache TTL const DEFAULT_EDITOR_CMD = 'code' // v0.6: POST /open-in-editor launches this on the host +// v0.7 Walk-away Workbench defaults +// A1 push notifications +const DEFAULT_PUSH_MAX_SUBS = 50 +// A4 timeline +const DEFAULT_TIMELINE_MAX = 200 +// A5 stuck detection +const DEFAULT_STUCK_TTL_SEC = 600 // 10 minutes (env var in seconds) +// B1 git diff +const DEFAULT_DIFF_TIMEOUT_MS = 2_000 +const DEFAULT_DIFF_MAX_BYTES = 2 * 1024 * 1024 // 2 MB +const DEFAULT_DIFF_MAX_FILES = 300 +// B2 statusline telemetry +const DEFAULT_STATUSLINE_TTL_MS = 30_000 +// B3 worktrees +const DEFAULT_WORKTREE_TIMEOUT_MS = 10_000 + +/** Valid --permission-mode values (R0-confirmed). Whitelist for validation. */ +const PERMISSION_MODES: readonly PermissionMode[] = ['default', 'acceptEdits', 'plan', 'auto'] + // ── helpers ─────────────────────────────────────────────────────────────────── /** Parse a non-negative integer env value (0 allowed), or the fallback when unset. */ @@ -63,6 +94,31 @@ function parseBool(raw: string | undefined, fallback: boolean): boolean { return fallback } +/** Parse a seconds-valued env var and return the equivalent milliseconds, or fallback (ms). */ +function parseSecondsToMs( + raw: string | undefined, + label: string, + fallbackMs: number, +): number { + if (raw === undefined) return fallbackMs + const n = Number(raw) + if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) { + throw new Error( + `Invalid config: ${label}=${JSON.stringify(raw)} — must be a non-negative integer (seconds)`, + ) + } + return n * 1000 +} + +/** Parse and validate a PermissionMode env var; throws for unknown values (including empty string). */ +function parsePermissionMode(raw: string | undefined, label: string): PermissionMode { + if (raw === undefined) return 'default' + if ((PERMISSION_MODES as readonly string[]).includes(raw)) return raw as PermissionMode + throw new Error( + `Invalid config: ${label}=${JSON.stringify(raw)} — must be one of ${PERMISSION_MODES.join('|')}`, + ) +} + /** Parse PROJECT_ROOTS (comma-separated absolute paths); expand a leading '~' to homeDir. * Empty / unset → [homeDir]. */ function parseProjectRoots(raw: string | undefined, homeDir: string): readonly string[] { @@ -176,7 +232,15 @@ function deriveAllowedOrigins(port: number, extraEnv: string | undefined): reado * Invalid values (port out of range, TTL non-integer, etc.) throw immediately * so the server never starts in a misconfigured state (fail-fast). * - * The returned Config is Object.freeze'd — immutable after construction. + * The returned object is Object.freeze'd — immutable after construction. It + * contains all existing Config fields plus 21 new v0.7 Walk-away Workbench + * fields. The return type is inferred (wider than Config) so callers can access + * the new fields without explicit type assertions. Functions accepting Config + * still work because the wider type is structurally assignable to Config. + * + * L5 (SEC-M4): permTimeoutMs must be > 0 — it is used to compute the + * --max-time flag in setup-hooks.mjs (T-hooks-installer). Zero would make + * --max-time=0 which disables the curl timeout entirely. */ export function loadConfig(env: EnvLike): Config { const port = parsePort(env['PORT']) @@ -218,6 +282,13 @@ export function loadConfig(env: EnvLike): Config { DEFAULT_PERM_TIMEOUT_MS, ) + // L5 / SEC-M4: permTimeoutMs must be > 0 (T-hooks-installer uses it for --max-time) + if (permTimeoutMs === 0) { + throw new Error( + 'Invalid config: PERM_TIMEOUT_MS must be > 0 (used to calculate --max-time for setup-hooks)', + ) + } + const reapIntervalMs = parseNonNegativeInt( env['REAP_INTERVAL_MS'], 'REAP_INTERVAL_MS', @@ -245,7 +316,76 @@ export function loadConfig(env: EnvLike): Config { const projectDirtyCheck = parseBool(env['PROJECT_DIRTY_CHECK'], true) const editorCmd = env['EDITOR_CMD']?.trim() || DEFAULT_EDITOR_CMD - return Object.freeze({ + // ── v0.7 Walk-away Workbench — 21 new env vars ───────────────────────────── + + // A1 push notifications (SEC-C5: vapidPrivateKey never logged by this module) + const vapidPublicKey = env['VAPID_PUBLIC_KEY'] || undefined + const vapidPrivateKey = env['VAPID_PRIVATE_KEY'] || undefined // SECRET — never log + const vapidSubject = env['VAPID_SUBJECT'] || undefined + const pushStorePath = + env['PUSH_STORE_PATH'] || path.join(homeDir, '.web-terminal-push-subs.json') + const pushMaxSubs = parseNonNegativeInt(env['PUSH_MAX_SUBS'], 'PUSH_MAX_SUBS', DEFAULT_PUSH_MAX_SUBS) + const notifyDone = parseBool(env['NOTIFY_DONE'], true) // default on + const notifyDnd = parseBool(env['NOTIFY_DND'], false) // default off (do not disturb) + // DECISION_TOKEN_TTL_MS defaults to permTimeoutMs (parsed after it, order matters) + const decisionTokenTtlMs = parseNonNegativeInt( + env['DECISION_TOKEN_TTL_MS'], + 'DECISION_TOKEN_TTL_MS', + permTimeoutMs, + ) + + // A4 activity timeline + const timelineMax = parseNonNegativeInt(env['TIMELINE_MAX'], 'TIMELINE_MAX', DEFAULT_TIMELINE_MAX) + const timelineEnabled = parseBool(env['TIMELINE_ENABLED'], true) + + // A5 stuck detection (STUCK_TTL env var is in seconds; stored in ms) + const stuckTtlMs = parseSecondsToMs(env['STUCK_TTL'], 'STUCK_TTL', DEFAULT_STUCK_TTL_SEC * 1000) + const stuckAlert = parseBool(env['STUCK_ALERT'], true) + + // B1 git diff viewer + const diffTimeoutMs = parseNonNegativeInt( + env['DIFF_TIMEOUT_MS'], + 'DIFF_TIMEOUT_MS', + DEFAULT_DIFF_TIMEOUT_MS, + ) + const diffMaxBytes = parseNonNegativeInt( + env['DIFF_MAX_BYTES'], + 'DIFF_MAX_BYTES', + DEFAULT_DIFF_MAX_BYTES, + ) + const diffMaxFiles = parseNonNegativeInt( + env['DIFF_MAX_FILES'], + 'DIFF_MAX_FILES', + DEFAULT_DIFF_MAX_FILES, + ) + + // B2 statusLine telemetry + const statuslineTtlMs = parseNonNegativeInt( + env['STATUSLINE_TTL_MS'], + 'STATUSLINE_TTL_MS', + DEFAULT_STATUSLINE_TTL_MS, + ) + + // B3 git worktree creation + const worktreeEnabled = parseBool(env['WORKTREE_ENABLED'], true) + const worktreeRoot = env['WORKTREE_ROOT'] || undefined // undefined → computed at creation time + const worktreeTimeoutMs = parseNonNegativeInt( + env['WORKTREE_TIMEOUT_MS'], + 'WORKTREE_TIMEOUT_MS', + DEFAULT_WORKTREE_TIMEOUT_MS, + ) + + // B4 permission mode relay + const defaultPermissionMode = parsePermissionMode( + env['DEFAULT_PERMISSION_MODE'], + 'DEFAULT_PERMISSION_MODE', + ) + const allowAutoMode = parseBool(env['ALLOW_AUTO_MODE'], false) // default off (SEC-M5) + + // ── assemble + freeze ─────────────────────────────────────────────────────── + // `satisfies Config` on the base object verifies all existing Config fields + // are present without triggering excess-property checks on the v0.7 additions. + const base = { port, bindHost, shellPath, @@ -266,5 +406,31 @@ export function loadConfig(env: EnvLike): Config { projectScanTtlMs, projectDirtyCheck, editorCmd, - } satisfies Config) + } + + return Object.freeze({ + ...base, + // v0.7 additions (21 new fields) + vapidPublicKey, + vapidPrivateKey, + vapidSubject, + pushStorePath, + pushMaxSubs, + notifyDone, + notifyDnd, + decisionTokenTtlMs, + timelineMax, + timelineEnabled, + stuckTtlMs, + stuckAlert, + diffTimeoutMs, + diffMaxBytes, + diffMaxFiles, + statuslineTtlMs, + worktreeEnabled, + worktreeRoot, + worktreeTimeoutMs, + defaultPermissionMode, + allowAutoMode, + }) } diff --git a/src/http/diff.ts b/src/http/diff.ts new file mode 100644 index 0000000..2949c52 --- /dev/null +++ b/src/http/diff.ts @@ -0,0 +1,365 @@ +/** + * src/http/diff.ts (N-diff-be, B1) — read-only structured git diff. + * + * The server stays a byte-shuttle: this is an out-of-band side-channel that runs + * `git diff` in a directory and PARSES its text into DiffFile/DiffLine. Parsing + * lives ONLY here (public/diff.ts is render-only — review #2). The frontend never + * re-derives diff structure; it only renders these objects with textContent. + * + * Security (SP4, §B1.4): + * - execFile('git', [...]) with NO shell; timeout + maxBuffer bound DoS (SEC-M9). + * - the trailing `--` terminates options so a path can't be read as a flag. + * (path → repo three-way validation lives in the ROUTE layer, SEC-H7.) + * - parsers NEVER throw: garbage lines degrade to `context`; getDiff is + * best-effort and returns an empty result rather than rejecting (house style). + * - diff content is carried verbatim in DiffLine.text — the FE renders it as + * inert text (AC-B1.4), never HTML. + * + * FR-B1.9 (`?base=`) is intentionally deferred to P2 (review #13): it needs + * a `git rev-parse --verify` allow-list before any revision reaches the CLI. + */ + +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import type { + Config, + DiffFile, + DiffHunk, + DiffLine, + DiffLineKind, + DiffResult, + FileStatus, +} from '../types.js' + +const execFileAsync = promisify(execFile) + +// ── numstat (pure) ────────────────────────────────────────────────────────── + +/** One `git diff --numstat` row: `\t\t`; binary = `-\t-`. */ +export interface NumstatEntry { + added: number + removed: number + binary: boolean +} + +/** Non-negative integer or 0 for `-`/junk (never NaN). */ +function toCount(field: string): number { + const n = Number.parseInt(field, 10) + return Number.isFinite(n) && n >= 0 ? n : 0 +} + +/** + * Expand a numstat path field into its old/new forms. A rename is shown either + * as `old => new` or, with a shared prefix/suffix, as `pre/{old => new}/suf`. + * Non-renames return the same path for both. + */ +function expandNumstatPath(raw: string): { oldPath: string; newPath: string } { + const braced = /^(.*)\{(.*) => (.*)\}(.*)$/.exec(raw) + if (braced !== null) { + const [, pre = '', oldMid = '', newMid = '', suf = ''] = braced + const collapse = (s: string): string => (pre + s + suf).replace(/\/{2,}/g, '/') + return { oldPath: collapse(oldMid), newPath: collapse(newMid) } + } + const arrow = raw.split(' => ') + if (arrow.length === 2) { + return { oldPath: arrow[0]?.trim() ?? raw, newPath: arrow[1]?.trim() ?? raw } + } + return { oldPath: raw, newPath: raw } +} + +/** + * Parse `git diff --numstat` output into a path → counts map. Renames are keyed + * under BOTH old and new paths so the unified-diff parser can find them by + * whichever path it derived. Malformed lines are skipped; never throws. + */ +export function parseNumstat(out: string): Map { + const map = new Map() + if (typeof out !== 'string') return map + for (const line of out.split('\n')) { + if (line.trim() === '') continue + const parts = line.split('\t') + if (parts.length < 3) continue + const addStr = parts[0] ?? '' + const remStr = parts[1] ?? '' + const rawPath = parts.slice(2).join('\t') + const binary = addStr === '-' && remStr === '-' + const entry: NumstatEntry = { + added: binary ? 0 : toCount(addStr), + removed: binary ? 0 : toCount(remStr), + binary, + } + const { oldPath, newPath } = expandNumstatPath(rawPath) + map.set(newPath, entry) + if (oldPath !== newPath) map.set(oldPath, entry) + } + return map +} + +// ── unified diff (pure) ────────────────────────────────────────────────────── + +/** Strip git's `a/`/`b/` prefix and optional C-quoting; `/dev/null` is kept. */ +function stripDiffPath(raw: string): string { + let s = raw.trim() + if (s === '/dev/null') return s + if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) { + s = s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\') + } + if (s.startsWith('a/') || s.startsWith('b/')) s = s.slice(2) + return s +} + +/** Best-effort path extraction from a `diff --git a/x b/y` header line. The + * authoritative paths come from ---/+++/rename lines, which override this. */ +function parseDiffGitLine(line: string): { oldPath: string; newPath: string } { + const rest = line.slice('diff --git '.length) + const sep = rest.indexOf(' b/') + if (sep !== -1) { + return { oldPath: stripDiffPath(rest.slice(0, sep)), newPath: stripDiffPath(rest.slice(sep + 1)) } + } + const p = stripDiffPath(rest) + return { oldPath: p, newPath: p } +} + +/** Classify one in-hunk line by its leading marker; unknown → context (spec). */ +function classifyHunkLine(line: string): DiffLine { + const marker = line.charAt(0) + if (marker === '+') return { kind: 'added', text: line.slice(1) } + if (marker === '-') return { kind: 'removed', text: line.slice(1) } + if (marker === ' ') return { kind: 'context', text: line.slice(1) } + if (marker === '\\') return { kind: 'meta', text: line.slice(1).trim() } + return { kind: 'context', text: line } +} + +interface BlockState { + oldPath: string + newPath: string + binary: boolean + isNew: boolean + isDeleted: boolean + isRename: boolean +} + +/** Apply one pre-hunk header line to the accumulating block state. */ +function applyHeaderLine(st: BlockState, line: string): void { + if (line.startsWith('diff --git ')) { + const p = parseDiffGitLine(line) + st.oldPath = p.oldPath + st.newPath = p.newPath + } else if (line.startsWith('new file')) st.isNew = true + else if (line.startsWith('deleted file')) st.isDeleted = true + else if (line.startsWith('rename from ')) { + st.oldPath = stripDiffPath(line.slice('rename from '.length)) + st.isRename = true + } else if (line.startsWith('rename to ')) { + st.newPath = stripDiffPath(line.slice('rename to '.length)) + st.isRename = true + } else if (line.startsWith('copy from ')) st.oldPath = stripDiffPath(line.slice('copy from '.length)) + else if (line.startsWith('copy to ')) st.newPath = stripDiffPath(line.slice('copy to '.length)) + else if (line.startsWith('--- ')) applyOldPath(st, line.slice(4)) + else if (line.startsWith('+++ ')) applyNewPath(st, line.slice(4)) + else if (line.startsWith('Binary files')) st.binary = true +} + +function applyOldPath(st: BlockState, raw: string): void { + if (raw.trim() === '/dev/null') st.isNew = true + else st.oldPath = stripDiffPath(raw) +} + +function applyNewPath(st: BlockState, raw: string): void { + if (raw.trim() === '/dev/null') st.isDeleted = true + else st.newPath = stripDiffPath(raw) +} + +function deriveStatus(st: BlockState, binary: boolean): FileStatus { + if (st.isRename) return 'renamed' + if (st.isNew) return 'added' + if (st.isDeleted) return 'deleted' + if (binary) return 'binary' + return 'modified' +} + +function countKind(hunks: readonly DiffHunk[], kind: DiffLineKind): number { + let n = 0 + for (const h of hunks) for (const l of h.lines) if (l.kind === kind) n += 1 + return n +} + +function finalizeFile( + st: BlockState, + hunks: DiffHunk[], + numstat?: Map, +): DiffFile { + const stat = numstat?.get(st.newPath) ?? numstat?.get(st.oldPath) + const binary = st.binary || stat?.binary === true + const added = stat?.added ?? countKind(hunks, 'added') + const removed = stat?.removed ?? countKind(hunks, 'removed') + return { + oldPath: st.oldPath, + newPath: st.newPath, + status: deriveStatus(st, binary), + added, + removed, + binary, + hunks, + } +} + +/** Parse one `diff --git` block (header lines + hunks) into a DiffFile. */ +function parseFileBlock(block: readonly string[], numstat?: Map): DiffFile | null { + const st: BlockState = { + oldPath: '', + newPath: '', + binary: false, + isNew: false, + isDeleted: false, + isRename: false, + } + const hunks: DiffHunk[] = [] + let current: DiffHunk | null = null + for (const line of block) { + if (line.startsWith('@@')) { + current = { header: line, lines: [] } + hunks.push(current) + } else if (current === null) { + applyHeaderLine(st, line) + } else { + current.lines.push(classifyHunkLine(line)) + } + } + if (st.oldPath === '' && st.newPath === '') return null + return finalizeFile(st, hunks, numstat) +} + +/** + * Parse a full unified `git diff` patch into DiffFile[]. `numstat` (optional) + * supplies authoritative +/- counts and binary flags; without it counts are + * derived from the hunk bodies. Empty / non-diff input → []; never throws. + */ +export function parseUnifiedDiff(patch: string, numstat?: Map): DiffFile[] { + if (typeof patch !== 'string' || patch.length === 0) return [] + const lines = patch.replace(/\n$/, '').split('\n') + const files: DiffFile[] = [] + let i = 0 + while (i < lines.length) { + if (lines[i]?.startsWith('diff --git ') !== true) { + i += 1 + continue + } + const start = i + i += 1 + while (i < lines.length && lines[i]?.startsWith('diff --git ') !== true) i += 1 + const file = parseFileBlock(lines.slice(start, i), numstat) + if (file !== null) files.push(file) + } + return files +} + +// ── getDiff (git runner) ───────────────────────────────────────────────────── + +/** Just the diff limits getDiff needs; the full Config satisfies this Pick. */ +export interface GetDiffOptions { + staged: boolean + cfg: Pick +} + +interface ExecErrorShape { + code?: string + stdout?: string +} + +function asExecError(err: unknown): ExecErrorShape { + if (err === null || typeof err !== 'object') return {} + const e = err as { code?: unknown; stdout?: unknown } + return { + code: typeof e.code === 'string' ? e.code : undefined, + stdout: typeof e.stdout === 'string' ? e.stdout : undefined, + } +} + +interface GitOutput { + out: string + truncated: boolean +} + +/** + * Run a read-only git command, capturing stdout. Bounded by timeout + maxBuffer + * (DoS guard). On a maxBuffer overflow the partial stdout is returned with + * `truncated:true`; any other failure yields empty output (best-effort). + */ +async function runGit( + cwd: string, + args: readonly string[], + timeoutMs: number, + maxBytes: number, +): Promise { + try { + const { stdout } = await execFileAsync('git', args, { + cwd, + timeout: timeoutMs, + maxBuffer: maxBytes, + }) + return { out: stdout, truncated: stdout.length >= maxBytes } + } catch (err: unknown) { + const { code, stdout } = asExecError(err) + if (code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') { + return { out: stdout ?? '', truncated: true } + } + return { out: '', truncated: false } + } +} + +/** Minimal unquote of a C-quoted porcelain path (`"a\"b"` → `a"b`). */ +function unquotePorcelain(raw: string): string { + const s = raw.trim() + if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) { + return s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\') + } + return s +} + +/** Untracked files (`git status --porcelain` `??` rows) as `untracked` DiffFiles. + * Content is not diffed (we avoid the `--no-index` two-operand trap, L3). */ +async function listUntracked(cwd: string, timeoutMs: number, maxBytes: number): Promise { + const { out } = await runGit(cwd, ['status', '--porcelain', '--'], timeoutMs, maxBytes) + const files: DiffFile[] = [] + for (const line of out.split('\n')) { + if (!line.startsWith('?? ')) continue + const p = unquotePorcelain(line.slice(3)) + if (p === '') continue + files.push({ + oldPath: p, + newPath: p, + status: 'untracked', + added: 0, + removed: 0, + binary: false, + hunks: [], + }) + } + return files +} + +/** + * Read a repo's diff (working tree or `--staged`) as structured DiffResult. + * `repoPath` must already be a validated absolute git directory (route layer, + * SEC-H7). Best-effort: git failures yield an empty result rather than throwing. + */ +export async function getDiff(repoPath: string, opts: GetDiffOptions): Promise { + const { staged, cfg } = opts + const { diffTimeoutMs: timeout, diffMaxBytes: maxBytes, diffMaxFiles } = cfg + const stagedArg = staged ? ['--staged'] : [] + + const patch = await runGit(repoPath, ['diff', '--no-color', ...stagedArg, '--'], timeout, maxBytes) + const num = await runGit(repoPath, ['diff', '--numstat', ...stagedArg, '--'], timeout, maxBytes) + + const files = parseUnifiedDiff(patch.out, parseNumstat(num.out)) + if (!staged) { + files.push(...(await listUntracked(repoPath, timeout, maxBytes))) + } + + let truncated = patch.truncated || num.truncated + const bounded = + files.length > diffMaxFiles ? ((truncated = true), files.slice(0, diffMaxFiles)) : files + + return { files: bounded, staged, truncated } +} diff --git a/src/http/hook.ts b/src/http/hook.ts index 4f9f9c2..0fd2016 100644 --- a/src/http/hook.ts +++ b/src/http/hook.ts @@ -1,15 +1,20 @@ /** * src/http/hook.ts (H2) — map a Claude Code hook POST to a session status update. * - * Pure + never throws. The web-terminal sessionId arrives in the + * Pure + never throws (SEC-M7). The web-terminal sessionId arrives in the * `X-Webterm-Session` header (injected as $WEBTERM_SESSION on spawn); the hook * payload arrives as JSON in the body. We translate the event into a coarse * ClaudeStatus the UI can show. The server is still a byte-shuttle for the * terminal stream — this is an out-of-band side-channel. + * + * T-hook-intake (v0.7): parseHookEvent now returns HookEventFull, which adds + * `at` (server timestamp), `eventClass` (raw hook name), `toolInput` (raw + * passthrough), and `gate` ('plan' | 'tool') for permission-mode relay (B4). */ -import type { ClaudeStatus } from '../types.js'; +import type { ClaudeStatus, PermissionGate } from '../types.js'; +/** Base shape kept for backward compat; new callers should use HookEventFull. */ export interface HookEvent { sessionId: string; status: ClaudeStatus; @@ -17,16 +22,56 @@ export interface HookEvent { } /** - * @param sessionId value of the `X-Webterm-Session` header - * @param body parsed JSON body of the hook POST (untrusted) - * @returns a status update, or null if the payload is unusable + * Full event shape returned by parseHookEvent (A4 / B4). + * + * `at` — server Date.now() at parse time (timeline anchor). + * `eventClass` — the raw hook_event_name from the whitelist, passed as-is + * so callers (timeline, manager) can make semantic decisions + * without re-parsing. + * `toolInput` — raw tool_input value from the JSON body, unknown type, + * passed through verbatim for timeline / future use. + * `gate` — 'plan' for an ExitPlanMode PermissionRequest (three-way + * approve / auto / keep-planning); 'tool' for any other + * PermissionRequest; absent for all other events. */ -export function parseHookEvent(sessionId: string | undefined, body: unknown): HookEvent | null { +export interface HookEventFull extends HookEvent { + at: number; + eventClass: string; + toolInput?: unknown; + gate?: PermissionGate; +} + +/** Whitelist of hook_event_name values we recognise. Unknown names → null. */ +const ALLOWED_EVENTS = new Set([ + 'PreToolUse', + 'PostToolUse', + 'UserPromptSubmit', + 'PermissionRequest', + 'Stop', + 'SessionEnd', + 'Notification', +]); + +/** + * Parse a Claude Code hook POST into a HookEventFull. + * + * @param sessionId Value of the `X-Webterm-Session` header. + * @param body Parsed JSON body of the hook POST (untrusted, unknown). + * @returns A HookEventFull, or null when the payload is unusable. + * Never throws (SEC-M7: unknown + narrowing). + */ +export function parseHookEvent( + sessionId: string | undefined, + body: unknown, +): HookEventFull | null { if (typeof sessionId !== 'string' || sessionId.length === 0) return null; - if (body === null || typeof body !== 'object') return null; + if (body === null || typeof body !== 'object' || Array.isArray(body)) return null; const b = body as Record; const event = typeof b['hook_event_name'] === 'string' ? b['hook_event_name'] : ''; + + if (!ALLOWED_EVENTS.has(event)) return null; + const notif = typeof b['notification_type'] === 'string' ? b['notification_type'] : ''; const tool = typeof b['tool_name'] === 'string' ? b['tool_name'] : undefined; @@ -48,10 +93,32 @@ export function parseHookEvent(sessionId: string | undefined, body: unknown): Ho status = notif === 'permission_prompt' ? 'waiting' : 'idle'; break; default: + // Unreachable: ALLOWED_EVENTS guard above catches unknown names. return null; } - return status === 'waiting' && tool !== undefined - ? { sessionId, status, detail: tool } - : { sessionId, status }; + // detail: only when waiting + a tool name is present. + const detail = status === 'waiting' && tool !== undefined ? tool : undefined; + + // toolInput: pass through verbatim when the key is present (even if null). + const hasToolInput = 'tool_input' in b; + const toolInput = hasToolInput ? b['tool_input'] : undefined; + + // gate: classify the permission gate type (B4). + const gate: PermissionGate | undefined = + event === 'PermissionRequest' + ? tool === 'ExitPlanMode' + ? 'plan' + : 'tool' + : undefined; + + return { + sessionId, + status, + at: Date.now(), + eventClass: event, + ...(detail !== undefined ? { detail } : {}), + ...(hasToolInput ? { toolInput } : {}), + ...(gate !== undefined ? { gate } : {}), + }; } diff --git a/src/http/statusline.ts b/src/http/statusline.ts new file mode 100644 index 0000000..38f51ed --- /dev/null +++ b/src/http/statusline.ts @@ -0,0 +1,131 @@ +/** + * src/http/statusline.ts — parse a Claude Code statusLine JSON body (B2). + * + * Pure function, never throws. Maps the statusLine stdin JSON (R0-confirmed + * field names, see StatusTelemetry comment in types.ts) into a StatusTelemetry. + * + * Security: SEC-M7 — unknown input narrowed field-by-field; string lengths capped. + * + * Field mapping (JSON → StatusTelemetry): + * context_window.used_percentage → contextUsedPct + * cost.total_cost_usd → costUsd + * cost.total_lines_added → linesAdded + * cost.total_lines_removed → linesRemoved + * model.display_name → model + * effort.level → effort + * pr → pr (same shape, required: number+url) + * rate_limits → rate (fiveHourPct, sevenDayPct) + */ + +import type { StatusTelemetry } from '../types.js'; + +// String length limits (SEC-M7 — prevent DoS through overly long strings) +const MODEL_MAX_LEN = 200; +const EFFORT_MAX_LEN = 100; +const URL_MAX_LEN = 2000; +const REVIEW_STATE_MAX_LEN = 100; + +/** + * Parse the body of a POST /hook/status request into a StatusTelemetry. + * + * @param body - raw untrusted JSON body (unknown type) + * @returns StatusTelemetry with at always set to Date.now(), or null when + * body is not a plain object. Missing/dirty fields → undefined. + * Never throws. + */ +export function parseStatusLine(body: unknown): StatusTelemetry | null { + // Non-objects (including null and arrays) → null + if (body === null || typeof body !== 'object' || Array.isArray(body)) return null; + + const b = body as Record; + + const contextUsedPct = safeFiniteNumber(nestedValue(b, 'context_window', 'used_percentage')); + + const costObj = asObjectOrNull(b['cost']); + const costUsd = safeFiniteNumber(costObj?.['total_cost_usd']); + const linesAdded = safeFiniteNumber(costObj?.['total_lines_added']); + const linesRemoved = safeFiniteNumber(costObj?.['total_lines_removed']); + + const modelObj = asObjectOrNull(b['model']); + const model = safeBoundedString(modelObj?.['display_name'], MODEL_MAX_LEN); + + const effortObj = asObjectOrNull(b['effort']); + const effort = safeBoundedString(effortObj?.['level'], EFFORT_MAX_LEN); + + const pr = parsePr(b['pr']); + const rate = parseRate(b['rate_limits']); + + return { + at: Date.now(), + ...(contextUsedPct !== undefined && { contextUsedPct }), + ...(costUsd !== undefined && { costUsd }), + ...(linesAdded !== undefined && { linesAdded }), + ...(linesRemoved !== undefined && { linesRemoved }), + ...(model !== undefined && { model }), + ...(effort !== undefined && { effort }), + ...(pr !== undefined && { pr }), + ...(rate !== undefined && { rate }), + }; +} + +// ─── Private helpers ───────────────────────────────────────────────────────── + +/** Get obj[key1][key2], or undefined if either level is not a plain object. */ +function nestedValue(obj: Record, key1: string, key2: string): unknown { + const inner = asObjectOrNull(obj[key1]); + return inner !== null ? inner[key2] : undefined; +} + +/** Return v if it is a finite number; otherwise undefined. */ +function safeFiniteNumber(v: unknown): number | undefined { + return typeof v === 'number' && Number.isFinite(v) ? v : undefined; +} + +/** Return v if it is a string within maxLen; otherwise undefined. */ +function safeBoundedString(v: unknown, maxLen: number): string | undefined { + if (typeof v !== 'string') return undefined; + return v.length <= maxLen ? v : undefined; +} + +/** Cast v to a Record if it is a non-null, non-array object; otherwise null. */ +function asObjectOrNull(v: unknown): Record | null { + if (v === null || typeof v !== 'object' || Array.isArray(v)) return null; + return v as Record; +} + +/** Parse the pr field. Returns undefined unless both number (finite) and url (bounded) are valid. */ +function parsePr( + v: unknown, +): { number: number; url: string; reviewState?: string } | undefined { + const obj = asObjectOrNull(v); + if (obj === null) return undefined; + + const num = safeFiniteNumber(obj['number']); + if (num === undefined) return undefined; + + const url = safeBoundedString(obj['url'], URL_MAX_LEN); + if (url === undefined) return undefined; + + const reviewState = safeBoundedString(obj['reviewState'], REVIEW_STATE_MAX_LEN); + return { + number: num, + url, + ...(reviewState !== undefined && { reviewState }), + }; +} + +/** Parse rate_limits → rate. Returns undefined if rate_limits is not an object. + * Returns a (possibly empty) object with undefined sub-fields when the object + * exists but sub-field values fail Number.isFinite validation. */ +function parseRate(v: unknown): { fiveHourPct?: number; sevenDayPct?: number } | undefined { + const obj = asObjectOrNull(v); + if (obj === null) return undefined; + + const fiveHourPct = safeFiniteNumber(obj['fiveHourPct']); + const sevenDayPct = safeFiniteNumber(obj['sevenDayPct']); + + return { + ...(fiveHourPct !== undefined && { fiveHourPct }), + ...(sevenDayPct !== undefined && { sevenDayPct }), + }; +} diff --git a/src/http/worktrees.ts b/src/http/worktrees.ts index a3f4798..c395f22 100644 --- a/src/http/worktrees.ts +++ b/src/http/worktrees.ts @@ -5,15 +5,22 @@ * record is the main worktree. Best-effort: a non-repo / git error yields []. */ +import fs from 'node:fs/promises' +import path from 'node:path' import { execFile } from 'node:child_process' import { promisify } from 'node:util' -import type { WorktreeInfo } from '../types.js' +import type { CreateWorktreeResult, WorktreeInfo } from '../types.js' const execFileAsync = promisify(execFile) const WORKTREE_TIMEOUT_MS = 2000 const WORKTREE_MAX_BUFFER = 1024 * 1024 +const MAX_BRANCH_LEN = 250 +// git ref-name forbidden punctuation subset + backslash (SEC-H2). Control chars +// and whitespace are matched separately via \s and the \x00-\x1f\x7f range. +const FORBIDDEN_BRANCH_CHARS = /[\x00-\x1f\x7f\s~^:?*[\\]/ + /** * Parse `git worktree list --porcelain`. `currentPath` flags which worktree is * the requested project (isCurrent). The first record is the main worktree. @@ -71,3 +78,174 @@ export async function listWorktrees(repoPath: string): Promise { return [] } } + +// ── B3: create a worktree (validate → contain → execFile, no shell) ───────────── + +/** + * Validate a user-supplied branch name against a git-ref-name subset (SEC-H2). + * Rejects: empty / >250 chars / leading '-' (flag injection) / '..' / trailing + * '.lock' or '.' / control chars / whitespace / ~^:?*[\ / '@{' / leading, + * trailing or consecutive '/'. Pure + unit-tested. + */ +export function validateBranchName(branch: string): boolean { + if (typeof branch !== 'string') return false + if (branch.length === 0 || branch.length > MAX_BRANCH_LEN) return false + if (branch.startsWith('-')) return false + if (branch.startsWith('/') || branch.endsWith('/') || branch.includes('//')) return false + if (branch.includes('..')) return false + if (branch.endsWith('.lock') || branch.endsWith('.')) return false + if (branch.includes('@{')) return false + if (FORBIDDEN_BRANCH_CHARS.test(branch)) return false + return true +} + +/** + * Reduce a branch name to a single safe filesystem directory segment: '/' → '-', + * any non-[A-Za-z0-9._-] char → '-', then trim leading/trailing dashes. Pure. + */ +export function sanitizeBranchForDir(branch: string): string { + return branch + .replace(/\//g, '-') + .replace(/[^A-Za-z0-9._-]/g, '-') + .replace(/^-+|-+$/g, '') +} + +/** + * Resolve symlinks on the longest existing prefix of `target`, re-appending any + * not-yet-existing trailing segments. Lets us realpath a path we're about to + * create without it existing yet (SEC-H3/M2). + */ +async function resolveRealPath(target: string): Promise { + let current = path.resolve(target) + const tail: string[] = [] + for (;;) { + try { + const real = await fs.realpath(current) + return tail.length === 0 ? real : path.join(real, ...tail.slice().reverse()) + } catch { + const parent = path.dirname(current) + if (parent === current) return path.resolve(target) // reached an unresolvable root + tail.push(path.basename(current)) + current = parent + } + } +} + +/** + * Compute the absolute directory for a new worktree and prove it is contained in + * the controlled base. base = `root ?? /-worktrees`. + * Both base and the candidate are realpath-resolved (symlinks followed) before a + * `startsWith(realBase + sep)` containment check — defeating symlinked-root / + * pre-planted-symlink escapes (M2). Throws when the candidate escapes the base. + */ +export async function computeWorktreeDir( + repoPath: string, + sanitized: string, + root?: string, +): Promise { + const base = + root ?? path.join(path.dirname(repoPath), path.basename(repoPath) + '-worktrees') + const candidate = path.join(base, sanitized) + const realBase = await resolveRealPath(base) + const realCandidate = await resolveRealPath(candidate) + if (realCandidate !== realBase && realCandidate.startsWith(realBase + path.sep)) { + return candidate + } + throw new Error('worktree path escapes the controlled root') +} + +/** True iff `/.git` exists (file or directory). */ +async function hasGitEntry(dir: string): Promise { + try { + await fs.stat(path.join(dir, '.git')) + return true + } catch { + return false + } +} + +/** Three-prong entry check: absolute + isDirectory + has a .git entry. */ +async function isGitRepo(repoPath: string): Promise { + if (typeof repoPath !== 'string' || !path.isAbsolute(repoPath)) return false + try { + const stat = await fs.stat(repoPath) + if (!stat.isDirectory()) return false + } catch { + return false + } + return hasGitEntry(repoPath) +} + +/** Pull a best-effort stderr string off a child_process error (never throws). */ +function extractStderr(err: unknown): string { + if (typeof err === 'object' && err !== null) { + const e = err as { stderr?: unknown; message?: unknown } + if (typeof e.stderr === 'string') return e.stderr + if (typeof e.message === 'string') return e.message + } + return '' +} + +/** + * Map a `git worktree add` failure to a structured result with a SAFE message + * (never raw git stderr, SEC-M10). Branch/path-exists collisions → 409, else 500. + */ +function classifyWorktreeError(err: unknown): CreateWorktreeResult { + const s = extractStderr(err).toLowerCase() + if (s.includes('already checked out') || s.includes('already used by worktree')) { + return { ok: false, status: 409, error: 'That branch is already checked out in another worktree.' } + } + if (s.includes('already exists') && s.includes('branch')) { + return { ok: false, status: 409, error: 'A branch with that name already exists.' } + } + if (s.includes('already exists')) { + return { ok: false, status: 409, error: 'The target directory already exists.' } + } + return { ok: false, status: 500, error: 'Failed to create the worktree.' } +} + +export interface CreateWorktreeOptions { + readonly base?: string // optional commit-ish to branch from (FR-B3.9) + readonly worktreeRoot?: string // cfg.worktreeRoot; undefined → -worktrees + readonly timeoutMs: number // cfg.worktreeTimeoutMs +} + +/** + * Create a git worktree on a new branch. Validates the branch, three-prong + * checks the repo, computes a contained target dir, then runs + * `git worktree add -b -- []` via execFile (no shell, `--` + * terminates options). Returns a structured CreateWorktreeResult; never throws. + */ +export async function createWorktree( + repoPath: string, + branch: string, + opts: CreateWorktreeOptions, +): Promise { + if (!validateBranchName(branch)) { + return { ok: false, status: 400, error: 'Invalid branch name.' } + } + if (!(await isGitRepo(repoPath))) { + return { ok: false, status: 404, error: 'Not a git repository.' } + } + + const sanitized = sanitizeBranchForDir(branch) + let dir: string + try { + dir = await computeWorktreeDir(repoPath, sanitized, opts.worktreeRoot) + } catch { + return { ok: false, status: 400, error: 'Resolved worktree path is out of bounds.' } + } + + try { + const args = ['worktree', 'add', '-b', branch, '--', dir] + if (opts.base !== undefined && opts.base !== '') args.push(opts.base) + await execFileAsync('git', args, { + cwd: repoPath, + timeout: opts.timeoutMs, + maxBuffer: WORKTREE_MAX_BUFFER, + }) + return { ok: true, path: dir, branch } + } catch (err: unknown) { + return classifyWorktreeError(err) + } +} diff --git a/src/push/push-service.ts b/src/push/push-service.ts new file mode 100644 index 0000000..89f40e6 --- /dev/null +++ b/src/push/push-service.ts @@ -0,0 +1,174 @@ +/** + * src/push/push-service.ts (N-push, A1) — VAPID-signed Web Push sender that + * implements `NotifyService`. + * + * The manager and server call `notify(session, cls, token?)` on the three + * proactive transitions (needs-input / done / stuck). This module signs a + * minimal `PushPayload` (§3.3) with VAPID and fans it out to every stored + * subscription, pruning ones the push service reports gone (404/410). + * + * Byte-shuttle boundary (SP1): the payload carries ONLY a session id, a short + * cwd-derived label, the notify class, and (needs-input only) the per-decision + * capability token — never raw terminal output and never a secret (SEC-C5). + * + * web-push is injected behind the `PushSender` seam so the signing/transport is + * fully unit-testable without real VAPID keys or network; the default sender + * wraps the `web-push` package. + */ + +import webpush from 'web-push'; +import { basename } from 'node:path'; +import type { + Config, + NotifyClass, + NotifyService, + PushPayload, + PushSubscriptionRecord, + Session, +} from '../types.js'; +import type { SubscriptionStore } from './subscription-store.js'; + +/** Fallback VAPID `sub` when VAPID_SUBJECT is unset (web-push requires one). */ +const DEFAULT_VAPID_SUBJECT = 'mailto:admin@localhost'; +/** TTL (seconds) for low-priority DONE/STUCK pushes — they need not linger long. */ +const DEFAULT_TTL_SECONDS = 600; +/** Push service "subscription gone" status codes → prune the subscription. */ +const GONE_STATUS = new Set([404, 410]); + +/** web-push send options we set (subset of web-push's RequestOptions). */ +export interface PushSendOptions { + TTL?: number; + urgency?: 'very-low' | 'low' | 'normal' | 'high'; + topic?: string; +} + +/** Transport seam over `web-push` (injectable for tests). */ +export interface PushSender { + setVapid(subject: string, publicKey: string, privateKey: string): void; + /** Resolves on accept; rejects with a `{ statusCode }`-bearing error on failure. */ + send(record: PushSubscriptionRecord, payload: string, options: PushSendOptions): Promise; +} + +export interface PushServiceDeps { + sender?: PushSender; +} + +/** Default sender backed by the `web-push` package. */ +function createWebPushSender(): PushSender { + return { + setVapid(subject, publicKey, privateKey) { + webpush.setVapidDetails(subject, publicKey, privateKey); + }, + async send(record, payload, options) { + await webpush.sendNotification( + { endpoint: record.endpoint, keys: record.keys }, + payload, + options, + ); + }, + }; +} + +/** Safely read a `statusCode` off an unknown thrown value (web-push WebPushError). */ +function statusCodeOf(err: unknown): number | undefined { + if (err !== null && typeof err === 'object' && 'statusCode' in err) { + const code = (err as { statusCode: unknown }).statusCode; + if (typeof code === 'number') return code; + } + return undefined; +} + +/** A valid Web Push `Topic` header is URL-safe base64, ≤32 chars. A UUID minus + * its dashes is exactly 32 chars of [0-9a-f] — collapses same-session pushes + * (same topic ⇒ the push service replaces, not stacks; A1-FR7). */ +function collapseTopic(sessionId: string): string { + return sessionId.replace(/-/g, '').replace(/[^A-Za-z0-9_]/g, '').slice(0, 32); +} + +function buildPayload(session: Session, cls: NotifyClass, token?: string): PushPayload { + const label = session.cwd ? basename(session.cwd) : undefined; + return { + sessionId: session.meta.id, + cls, + ...(label ? { detail: label } : {}), + ...(cls === 'needs-input' && token ? { token } : {}), + }; +} + +function buildSendOptions(session: Session, cls: NotifyClass, cfg: Config): PushSendOptions { + return { + urgency: cls === 'done' ? 'low' : 'high', + topic: collapseTopic(session.meta.id), + TTL: cls === 'needs-input' ? Math.ceil(cfg.decisionTokenTtlMs / 1000) : DEFAULT_TTL_SECONDS, + }; +} + +/** + * Create the push notification service. + * @param cfg frozen runtime config (VAPID keys, DND/done toggles) + * @param store subscription store (read for fan-out, pruned on 404/410) + * @param deps optional injected sender (defaults to the real web-push sender) + */ +export function createPushService( + cfg: Config, + store: SubscriptionStore, + deps: PushServiceDeps = {}, +): NotifyService { + const sender = deps.sender ?? createWebPushSender(); + const enabled = Boolean(cfg.vapidPublicKey && cfg.vapidPrivateKey); + + if (enabled) { + sender.setVapid( + cfg.vapidSubject ?? DEFAULT_VAPID_SUBJECT, + cfg.vapidPublicKey as string, + cfg.vapidPrivateKey as string, + ); + } + + function shouldSend(cls: NotifyClass): boolean { + if (!enabled) return false; + if (cfg.notifyDnd) return false; + if (cls === 'done' && !cfg.notifyDone) return false; + return true; + } + + async function sendOne( + record: PushSubscriptionRecord, + payload: string, + options: PushSendOptions, + dead: string[], + ): Promise { + try { + await sender.send(record, payload, options); + } catch (err) { + const code = statusCodeOf(err); + if (code !== undefined && GONE_STATUS.has(code)) { + dead.push(record.endpoint); + } else { + // Transient failure (network/5xx): keep the subscription, log without secrets. + console.error(`push-service: send failed (status ${code ?? 'unknown'})`); + } + } + } + + return { + isEnabled: () => enabled, + + async notify(session: Session, cls: NotifyClass, token?: string): Promise { + if (!shouldSend(cls)) return; + const records = store.list(); + if (records.length === 0) return; + + const payload = JSON.stringify(buildPayload(session, cls, token)); + const options = buildSendOptions(session, cls, cfg); + const dead: string[] = []; + + await Promise.all(records.map((record) => sendOne(record, payload, options, dead))); + + if (dead.length > 0) { + store.prune(dead); + await store.persist(); + } + }, + }; +} diff --git a/src/push/subscription-store.ts b/src/push/subscription-store.ts new file mode 100644 index 0000000..7cbff50 --- /dev/null +++ b/src/push/subscription-store.ts @@ -0,0 +1,112 @@ +/** + * src/push/subscription-store.ts (N-push, A1) — persistent store of browser + * Web Push subscriptions. + * + * Holds the `PushSubscriptionRecord`s the push-service signs and sends to. The + * file is SENSITIVE (endpoint + keys) — persisted with 0600 permissions and + * NEVER exposed via any GET route (SEC-M2). A `PUSH_MAX_SUBS` FIFO cap bounds + * memory/disk against a flood of subscribe calls (SEC-M1). + * + * All mutations are immutable: the internal array is REPLACED with a new array, + * never mutated in place; `list()` returns a frozen shallow copy. + * + * Loading is best-effort (A1): a missing file → empty; malformed JSON → empty + * (logged); invalid entries are filtered out. The push-service stays usable. + */ + +import { readFileSync } from 'node:fs'; +import { chmod, writeFile } from 'node:fs/promises'; +import type { PushSubscriptionRecord } from '../types.js'; + +/** 0600 — owner read/write only; the file holds subscription secrets (SEC-M2). */ +const FILE_MODE = 0o600; + +export interface SubscriptionStore { + /** Frozen snapshot of current subscriptions (immutable to callers). */ + list(): readonly PushSubscriptionRecord[]; + /** Add a record (dedup by endpoint, FIFO-capped at maxSubs). Throws on invalid input. */ + add(record: PushSubscriptionRecord): void; + /** Remove by endpoint; no-op when absent. */ + remove(endpoint: string): void; + /** Remove a batch of dead endpoints (404/410 from the push service). */ + prune(deadEndpoints: readonly string[]): void; + /** Persist to disk (0600). Best-effort: logs and resolves on failure. */ + persist(): Promise; +} + +/** Type guard: a structurally valid, persistable subscription record. */ +export function isValidSubscriptionRecord(x: unknown): x is PushSubscriptionRecord { + if (x === null || typeof x !== 'object') return false; + const r = x as Record; + if (typeof r['endpoint'] !== 'string' || r['endpoint'].length === 0) return false; + const keys = r['keys']; + if (keys === null || typeof keys !== 'object') return false; + const k = keys as Record; + if (typeof k['p256dh'] !== 'string' || typeof k['auth'] !== 'string') return false; + if (typeof r['createdAt'] !== 'number' || !Number.isFinite(r['createdAt'])) return false; + return true; +} + +function loadRecords(filePath: string): PushSubscriptionRecord[] { + let raw: string; + try { + raw = readFileSync(filePath, 'utf8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') { + console.error('subscription-store: load failed, starting empty', err); + } + return []; + } + try { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter(isValidSubscriptionRecord); + } catch (err) { + console.error('subscription-store: malformed JSON, starting empty', err); + return []; + } +} + +/** + * Load (or initialise empty) a subscription store backed by `filePath`. + * @param filePath absolute path to the JSON store (cfg.pushStorePath) + * @param maxSubs FIFO cap on stored subscriptions (cfg.pushMaxSubs) + */ +export function loadSubscriptionStore(filePath: string, maxSubs: number): SubscriptionStore { + let records: PushSubscriptionRecord[] = loadRecords(filePath); + + return { + list(): readonly PushSubscriptionRecord[] { + return Object.freeze(records.slice()); + }, + + add(record: PushSubscriptionRecord): void { + if (!isValidSubscriptionRecord(record)) { + throw new TypeError('subscription-store: invalid subscription record'); + } + const deduped = records.filter((r) => r.endpoint !== record.endpoint); + const next = [...deduped, record]; + records = next.length > maxSubs ? next.slice(next.length - maxSubs) : next; + }, + + remove(endpoint: string): void { + records = records.filter((r) => r.endpoint !== endpoint); + }, + + prune(deadEndpoints: readonly string[]): void { + if (deadEndpoints.length === 0) return; + const dead = new Set(deadEndpoints); + records = records.filter((r) => !dead.has(r.endpoint)); + }, + + async persist(): Promise { + try { + await writeFile(filePath, JSON.stringify(records, null, 2), { mode: FILE_MODE }); + // writeFile's mode only applies on creation; enforce 0600 if the file pre-existed. + await chmod(filePath, FILE_MODE); + } catch (err) { + console.error('subscription-store: persist failed', err); + } + }, + }; +} diff --git a/src/server.ts b/src/server.ts index c4defd9..b1ede86 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,23 +1,18 @@ /** - * src/server.ts (T14) — HTTP + WebSocket wiring layer. + * src/server.ts (T14 + T-server-wire) — HTTP + WebSocket wiring layer. * - * Responsibilities (ARCHITECTURE §3.6): - * 1. Express static hosting of public/ (including public/build/ esbuild output). - * 2. WebSocketServer in noServer mode (L3) — never auto-attaches its own upgrade. - * 3. HTTP 'upgrade' event (single entry point): - * - Wrong path → socket.destroy() - * - Origin not allowed → 401 + destroy() - * - Passes → wss.handleUpgrade → wss.emit('connection') - * 4. WS 'connection': wait for first 'attach' frame → handleAttach → send 'attached' - * catch (spawn failure, M4) → send exit(-1) + close (only this connection). - * 5. WS 'message': parse → ok:false discard+log; route input/resize. - * 6. WS 'close' → manager.detachWs (never kills PTY, invariant #2). - * 7. SIGINT/SIGTERM → manager.shutdown() + http server close. + * Responsibilities (ARCHITECTURE §3.6): static hosting; WS upgrade gate (wrong + * path → destroy; bad Origin → 401, L3); WS connection (first frame must be + * 'attach', M4 spawn-failure → exit(-1)+close); message routing; close → detach + * (never kills PTY, invariant #2); SIGINT/SIGTERM → shutdown. M5: every ws.send + * guarded by readyState === WS_OPEN. L5: maxPayload cap. wsPath from config (#8). * - * M5: every ws.send guarded by readyState === WS_OPEN. - * L3: noServer:true — no double upgrade handling. - * L5: maxPayload: cfg.maxPayloadBytes in WebSocketServer options. - * No hardcoded strings: wsPath comes from config (invariant 8). + * v0.7 (T-server-wire) adds out-of-band side-channel routes — the terminal + * stream stays a byte-shuttle. A1 push (/push/*, /hook/decision + C1 hold gate), + * A4 timeline (/…/events), A5 reaper sweepStuck, B1 diff (/projects/diff), B2 + * statusLine (/hook/status), B3 worktree (/projects/worktree), B4 approve mode, + * GET /config/ui. State-changing routes carry requireAllowedOrigin (CSRF) and + * per-IP rate limits; loopback-only ingest keeps isLoopback. * * Style: immutable data, explicit error handling, no silent swallowing. */ @@ -27,6 +22,8 @@ import net from 'node:net' import { URL } from 'node:url' import path from 'node:path' import { fileURLToPath } from 'node:url' +import { randomUUID } from 'node:crypto' +import { stat } from 'node:fs/promises' import express from 'express' import type { Response } from 'express' @@ -41,9 +38,20 @@ import { parseHookEvent } from './http/hook.js' import { listSessions } from './http/history.js' import { buildProjects, buildProjectDetail } from './http/projects.js' import { openInEditor } from './http/editor.js' +import { getDiff } from './http/diff.js' +import { parseStatusLine } from './http/statusline.js' +import { createWorktree } from './http/worktrees.js' import { createSessionManager } from './session/manager.js' import { detachWs, writeInput, setClientDims } from './session/session.js' -import type { Config } from './types.js' +import { loadSubscriptionStore } from './push/subscription-store.js' +import { createPushService } from './push/push-service.js' +import type { + Config, + PermissionGate, + PermissionMode, + PushSubscriptionRecord, + UiConfig, +} from './types.js' import { WS_OPEN } from './types.js' // ── defaults ────────────────────────────────────────────────────────────────── @@ -57,9 +65,78 @@ const RATE_WINDOW_MS = 1000 // Max chars of a user-controlled string written to a log line (M2 log injection). const LOG_FIELD_MAX = 200 -/** The decision JSON a PermissionRequest command hook writes to stdout. */ -function permDecision(behavior: 'allow' | 'deny'): unknown { - return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior } } } +// Per-IP sliding-window rate limits for the new state-changing routes (SEC-H9, +// review #9). Fixed security policy — not user config. +const RATE_LIMIT_WINDOW_MS = 60_000 +const DECISION_RATE_MAX = 10 // POST /hook/decision ≤ 10/min/IP +const SUBSCRIBE_RATE_MAX = 5 // POST/DELETE /push/subscribe ≤ 5/min/IP + +/** The four permission modes the WS approve relay accepts (B4); else undefined. */ +const PERMISSION_MODES: ReadonlySet = new Set([ + 'default', + 'acceptEdits', + 'plan', + 'auto', +]) + +/** Decision JSON a PermissionRequest hook writes to stdout (H3). `mode` (B4) + * carries the updated permission mode back to Claude on a plan-gate approval. */ +function permDecision(behavior: 'allow' | 'deny', mode?: PermissionMode): unknown { + const decision: Record = { behavior } + if (mode !== undefined) decision['mode'] = mode + return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision } } +} + +/** Whitelist-validate an optional `approve.mode` from a raw WS frame (B4). The + * shared browser-safe protocol parser cannot surface it (must stay DOM-free), + * so the WS wiring layer validates it here against PermissionMode. Never throws. */ +function parseApproveMode(raw: string): PermissionMode | undefined { + try { + const obj = JSON.parse(raw) as Record + const m = obj?.['mode'] + return typeof m === 'string' && PERMISSION_MODES.has(m) ? (m as PermissionMode) : undefined + } catch { + return undefined + } +} + +/** Per-IP sliding-window limiter (in-memory). true (and records) when under the + * cap for the window, false when over. Immutable per-IP arrays (filter → new). */ +function createRateLimiter(maxPerWindow: number, windowMs: number): (ip: string, now: number) => boolean { + const hits = new Map() + return (ip, now): boolean => { + const recent = (hits.get(ip) ?? []).filter((t) => now - t < windowMs) + if (recent.length >= maxPerWindow) { + hits.set(ip, recent) + return false + } + hits.set(ip, [...recent, now]) + return true + } +} + +/** Three-prong read-only diff path check (SEC-H7): absolute + dir + has .git. */ +async function isValidGitDir(target: string): Promise { + if (typeof target !== 'string' || !path.isAbsolute(target)) return false + try { + const s = await stat(target) + if (!s.isDirectory()) return false + await stat(path.join(target, '.git')) + return true + } catch { + return false + } +} + +/** Build a PushSubscriptionRecord from an untrusted POST body, or null. */ +function toSubscriptionRecord(body: Record): PushSubscriptionRecord | null { + const endpoint = body['endpoint'] + const keys = body['keys'] + if (typeof endpoint !== 'string' || endpoint.length === 0) return null + if (keys === null || typeof keys !== 'object') return null + const k = keys as Record + if (typeof k['p256dh'] !== 'string' || typeof k['auth'] !== 'string') return null + return { endpoint, keys: { p256dh: k['p256dh'], auth: k['auth'] }, createdAt: Date.now() } } /** @@ -107,11 +184,28 @@ function safeSend(ws: WsWebSocket, data: string): void { * both the HTTP server and the session manager. */ export function startServer(cfg: Config): { close(): Promise } { - const manager = createSessionManager(cfg) + // A1: persistent push-subscription store + VAPID-signed push sender, injected + // into the manager (DI, M5) so the manager never imports push-service directly. + const subStore = loadSubscriptionStore(cfg.pushStorePath, cfg.pushMaxSubs) + const pushService = createPushService(cfg, subStore) + const manager = createSessionManager(cfg, pushService) - // H3: held PermissionRequest responses, keyed by sessionId. The express `res` - // is parked here until the client approves/rejects (or it times out). - const pendingApprovals = new Map }>() + // Per-IP rate limiters for the new state-changing routes (SEC-H9). + const decisionLimiter = createRateLimiter(DECISION_RATE_MAX, RATE_LIMIT_WINDOW_MS) + const subscribeLimiter = createRateLimiter(SUBSCRIBE_RATE_MAX, RATE_LIMIT_WINDOW_MS) + + // H3/A1: held PermissionRequest responses, keyed by sessionId. `res` is parked + // until approve/reject or timeout. `token`+`expiresAt` = the per-decision + // capability a remote device presents at /hook/decision (SEC-C1/M1); `gate` + // (B4) lets a late-joining client re-render the right affordance (M3). + interface PendingApproval { + res: Response + timer: ReturnType + token: string + expiresAt: number + gate: PermissionGate + } + const pendingApprovals = new Map() function resolvePending(sessionId: string, decision: unknown): void { const p = pendingApprovals.get(sessionId) @@ -263,16 +357,25 @@ export function startServer(cfg: Config): { close(): Promise } { res.status(403).end() return } + const body = (req.body ?? {}) as Record const ev = parseHookEvent(req.header('x-webterm-session'), req.body) if (ev === null) { res.status(400).end() return } - manager.handleHookEvent(ev.sessionId, ev.status, ev.detail) + // A4: pass event class + tool name so the manager appends a sanitized + // timeline entry. Not a held gate → pending/gate stay undefined. + const tool = typeof body['tool_name'] === 'string' ? body['tool_name'] : undefined + manager.handleHookEvent(ev.sessionId, ev.status, ev.detail, undefined, undefined, ev.eventClass, tool) + // A1: DONE push on terminal-completing events (best-effort; honors NOTIFY_DONE). + if (ev.eventClass === 'Stop' || ev.eventClass === 'SessionEnd') { + const session = manager.get(ev.sessionId) + if (session !== undefined) void pushService.notify(session, 'done') + } res.status(204).end() }) - // H3: PermissionRequest hook — held until the client approves/rejects. The + // H3/A1: PermissionRequest hook — held until the client approves/rejects. The // hook's curl writes our response (the decision JSON) to stdout for Claude. app.post('/hook/permission', express.json({ limit: '64kb' }), (req, res) => { if (!isLoopback(req.socket.remoteAddress ?? '')) { @@ -284,22 +387,205 @@ export function startServer(cfg: Config): { close(): Promise } { const tool = typeof body['tool_name'] === 'string' ? body['tool_name'] : undefined const session = typeof sessionId === 'string' ? manager.get(sessionId) : undefined - // Nobody watching this tab (no session / no clients) → let Claude prompt itself. - if (sessionId === undefined || session === undefined || session.clients.size === 0) { + if (sessionId === undefined || session === undefined) { + res.json({}) // no such session → let Claude prompt itself + return + } + + // C1 hold gate (SEC-C2): hold if a client is watching OR push is enabled with + // ≥1 subscription (so "walk away with zero tabs" still triggers lock-screen + // approval); otherwise fall through to Claude's own prompt. + const hasWatcher = session.clients.size > 0 + const hasPushTarget = pushService.isEnabled() && subStore.list().length > 0 + if (!hasWatcher && !hasPushTarget) { res.json({}) return } + // B4: an ExitPlanMode request is a 'plan' gate (three-way), else a 'tool' gate. + const gate: PermissionGate = tool === 'ExitPlanMode' ? 'plan' : 'tool' + resolvePending(sessionId, {}) // clear any stale hold for this session + const token = randomUUID() + const expiresAt = Date.now() + cfg.decisionTokenTtlMs const timer = setTimeout(() => { pendingApprovals.delete(sessionId) res.json({}) // timeout → fall back to Claude's interactive prompt manager.handleHookEvent(sessionId, 'idle') }, cfg.permTimeoutMs) - pendingApprovals.set(sessionId, { res, timer }) + pendingApprovals.set(sessionId, { res, timer, token, expiresAt, gate }) - // Show the approve/reject affordance on the client. - manager.handleHookEvent(sessionId, 'waiting', tool, true) + // A1: push the lock-screen approval (carrying the capability token) to every + // subscribed device. Best-effort — failures are logged inside push-service. + void pushService.notify(session, 'needs-input', token) + + // Show the approve/reject affordance on every attached client. + manager.handleHookEvent(sessionId, 'waiting', tool, true, gate) + }) + + // ── A1 push subscription + lock-screen decision routes ──────────────────── + + // Public VAPID key for SW subscription. 503 when push is disabled (graceful). + app.get('/push/vapid-key', (_req, res) => { + if (!pushService.isEnabled() || cfg.vapidPublicKey === undefined) { + res.status(503).end() + return + } + res.json({ publicKey: cfg.vapidPublicKey }) + }) + + // Store a browser PushSubscription (state-changing → Origin guard + rate limit). + app.post('/push/subscribe', express.json({ limit: '8kb' }), async (req, res) => { + if (!requireAllowedOrigin(req, res)) return + if (!subscribeLimiter(req.socket.remoteAddress ?? '', Date.now())) { + res.status(429).end() + return + } + const record = toSubscriptionRecord((req.body ?? {}) as Record) + if (record === null) { + res.status(400).end() + return + } + try { + subStore.add(record) + await subStore.persist() + } catch { + res.status(400).end() + return + } + res.status(204).end() + }) + + // Remove a stored subscription (state-changing → Origin guard + rate limit). + app.delete('/push/subscribe', express.json({ limit: '8kb' }), async (req, res) => { + if (!requireAllowedOrigin(req, res)) return + if (!subscribeLimiter(req.socket.remoteAddress ?? '', Date.now())) { + res.status(429).end() + return + } + const endpoint = (req.body ?? {}) as Record + const value = endpoint['endpoint'] + if (typeof value !== 'string' || value.length === 0) { + res.status(400).end() + return + } + subStore.remove(value) + await subStore.persist() + res.status(204).end() + }) + + // Remote lock-screen Allow/Deny (SEC-C1): called by a REMOTE device's SW (not + // loopback) → guard with Origin + a per-decision capability token (stale/ + // mismatch → 403). + app.post('/hook/decision', express.json({ limit: '4kb' }), (req, res) => { + if (!requireAllowedOrigin(req, res)) return + if (!decisionLimiter(req.socket.remoteAddress ?? '', Date.now())) { + res.status(429).end() + return + } + const body = (req.body ?? {}) as Record + const sessionId = typeof body['sessionId'] === 'string' ? body['sessionId'] : undefined + const decision = body['decision'] + const token = typeof body['token'] === 'string' ? body['token'] : undefined + if (sessionId === undefined || token === undefined || (decision !== 'allow' && decision !== 'deny')) { + res.status(400).end() + return + } + const pending = pendingApprovals.get(sessionId) + if (pending === undefined || pending.token !== token || Date.now() > pending.expiresAt) { + res.status(403).end() // SEC-C1/M1: missing / mismatched / stale token + return + } + resolvePending(sessionId, permDecision(decision)) + manager.handleHookEvent(sessionId, 'working', undefined, false) + res.status(204).end() // SW does not read the body (review #14) + }) + + // ── A4 timeline (read-only discovery, no Origin guard) ──────────────────── + app.get('/live-sessions/:id/events', (req, res) => { + if (!cfg.timelineEnabled) { + res.json([]) // AC-A4.6: capture/serve disabled → empty + return + } + const session = manager.get(req.params.id) + if (session === undefined) { + res.status(404).end() + return + } + res.json(session.timeline) + }) + + // ── B1 read-only git diff (no Origin guard; same threat model as /projects) ─ + app.get('/projects/diff', async (req, res) => { + const target = req.query['path'] + if (typeof target !== 'string' || target === '') { + res.status(400).json({ error: 'path query parameter is required' }) + return + } + if (!(await isValidGitDir(target))) { + res.status(404).json({ error: 'project not found' }) // SEC-H7 three-prong + return + } + try { + const staged = req.query['staged'] === '1' + res.json(await getDiff(target, { staged, cfg })) + } catch (err) { + console.error('[server] /projects/diff failed:', err instanceof Error ? err.message : String(err)) + res.status(500).json({ error: 'failed to read diff' }) + } + }) + + // ── B2 statusLine telemetry ingest (loopback only, SEC-H1) ──────────────── + app.post('/hook/status', express.json({ limit: '64kb' }), (req, res) => { + if (!isLoopback(req.socket.remoteAddress ?? '')) { + res.status(403).end() + return + } + const telemetry = parseStatusLine(req.body) + if (telemetry === null) { + res.status(400).end() + return + } + const sessionId = req.header('x-webterm-session') + if (typeof sessionId === 'string' && sessionId.length > 0) { + manager.handleStatusLine(sessionId, telemetry) + } + res.status(204).end() + }) + + // ── B3 create a git worktree (the only write-to-disk feature → Origin guard) ─ + app.post('/projects/worktree', express.json({ limit: '4kb' }), async (req, res) => { + if (!requireAllowedOrigin(req, res)) return + if (!cfg.worktreeEnabled) { + res.status(403).json({ error: 'Worktree creation is disabled.' }) + return + } + const body = (req.body ?? {}) as Record + const repoPath = typeof body['path'] === 'string' ? body['path'] : undefined + const branch = typeof body['branch'] === 'string' ? body['branch'] : undefined + const base = typeof body['base'] === 'string' && body['base'] !== '' ? body['base'] : undefined + if (repoPath === undefined || branch === undefined) { + res.status(400).json({ error: 'path and branch are required' }) + return + } + // Audit (B3.4): who/what, sanitized + truncated (never raw control chars). + console.error(`[server] worktree create: branch=${sanitizeForLog(branch)} path=${sanitizeForLog(repoPath)}`) + const result = await createWorktree(repoPath, branch, { + base, + worktreeRoot: cfg.worktreeRoot, + timeoutMs: cfg.worktreeTimeoutMs, + }) + if (result.ok) { + res.status(200).json({ ok: true, path: result.path, branch: result.branch }) + return + } + res.status(result.status ?? 500).json({ error: result.error ?? 'Failed to create the worktree.' }) + }) + + // ── GET /config/ui (review #4) — client-readable UI config (read-only) ──── + app.get('/config/ui', (_req, res) => { + const uiConfig: UiConfig = { allowAutoMode: cfg.allowAutoMode } + res.json(uiConfig) }) // ── HTTP server ─────────────────────────────────────────────────────────── @@ -313,9 +599,11 @@ export function startServer(cfg: Config): { close(): Promise } { maxPayload: cfg.maxPayloadBytes, // L5: cap single WS frame size }) - // ── Idle reaper ─────────────────────────────────────────────────────────── + // ── Idle reaper (+ A5 stuck sweep, H4 — no new timer) ───────────────────── const reapTimer = setInterval(() => { - manager.reapIdle(Date.now()) + const now = Date.now() + manager.reapIdle(now) + manager.sweepStuck(now) }, cfg.reapIntervalMs) // Don't let this timer prevent the process from exiting. reapTimer.unref() @@ -421,6 +709,17 @@ export function startServer(cfg: Config): { close(): Promise } { // Confirm the attach to the client. safeSend(ws, serialize({ type: 'attached', sessionId: session.meta.id })) + + // M3 / AC-A1.3: a late-joining device immediately sees a still-held + // approval (the manager re-sent telemetry + status; the pending/gate + // portion lives here because the server owns pendingApprovals). + const heldApproval = pendingApprovals.get(boundSessionId) + if (heldApproval !== undefined) { + safeSend( + ws, + serialize({ type: 'status', status: 'waiting', pending: true, gate: heldApproval.gate }), + ) + } return } @@ -437,8 +736,12 @@ export function startServer(cfg: Config): { close(): Promise } { // Latest-writer-wins: this device's dims drive the shared PTY size. setClientDims(session, ws, msg.cols, msg.rows) } else if (msg.type === 'approve') { - // H3: resolve the held PermissionRequest with allow. - resolvePending(boundSessionId, permDecision('allow')) + // H3/B4: resolve the held PermissionRequest with allow, carrying the + // optional permission mode. SEC-M5: downgrade the high-risk 'auto' mode + // to 'default' unless ALLOW_AUTO_MODE is set (even if the FE hid it). + let mode = parseApproveMode(text) + if (mode === 'auto' && !cfg.allowAutoMode) mode = 'default' + resolvePending(boundSessionId, permDecision('allow', mode)) manager.handleHookEvent(boundSessionId, 'working', undefined, false) } else if (msg.type === 'reject') { resolvePending(boundSessionId, permDecision('deny')) diff --git a/src/session/manager.ts b/src/session/manager.ts index 5c42125..e60ff9a 100644 --- a/src/session/manager.ts +++ b/src/session/manager.ts @@ -30,13 +30,18 @@ import type { Config, Dims, LiveSessionInfo, + NotifyService, + PermissionGate, + ServerMessage, Session, SessionManager, + StatusTelemetry, WebSocketLike, } from '../types.js'; import { WS_OPEN } from '../types.js'; import { serialize } from '../protocol.js'; import { createSession, attachWs, broadcast, kill } from './session.js'; +import { appendEvent, makeTimelineEvent } from './timeline.js'; import { hasSession, tmuxName } from './tmux.js'; /** @@ -57,8 +62,16 @@ function sendIfOpen(ws: WebSocketLike | null, msg: Parameters[ * (add, delete) rather than in-place modification of the table structure. * Individual Session objects do carry mutable fields (attachedWs, detachedAt, * lastOutputAt, exitedAt, exitCode) by design — those are runtime handles. + * + * `notifyService` is injected (DI, M5) so the manager never imports push-service + * directly (avoids a manager↔push-service cycle). sweepStuck uses it to push the + * 'stuck' signal; when it is undefined (VAPID unset / tests) stuck detection still + * updates status + broadcasts, it just doesn't send a push. */ -export function createSessionManager(cfg: Config): SessionManager { +export function createSessionManager( + cfg: Config, + notifyService?: NotifyService, +): SessionManager { let sessions: Map = new Map(); /** @@ -117,6 +130,14 @@ export function createSessionManager(cfg: Config): SessionManager { // ── Case 2: hit a live session → JOIN it (multi-device sharing) ─────────── if (existing !== undefined && existing.exitedAt === null) { attachWs(existing, ws); + // M3 / AC-B2.3: a late-joining device immediately gets the current + // telemetry (if any) and status so it doesn't wait for the next + // hook/statusLine report to refresh. The `pending`/`gate` portion of the + // status is re-sent by the server layer (it owns pendingApprovals). + if (existing.telemetry !== null) { + sendIfOpen(ws, { type: 'telemetry', telemetry: existing.telemetry }); + } + sendIfOpen(ws, { type: 'status', status: existing.claudeStatus }); return existing; } @@ -168,6 +189,7 @@ export function createSessionManager(cfg: Config): SessionManager { cwd: s.cwd, cols: s.pty.cols, rows: s.pty.rows, + telemetry: s.telemetry, // B2: latest telemetry for the thumbnail wall })) .sort((a, b) => b.createdAt - a.createdAt); } @@ -190,27 +212,77 @@ export function createSessionManager(cfg: Config): SessionManager { } /** - * H2: a Claude Code hook reported activity for `sessionId`. Record it and push - * a `status` frame to the attached ws (no-op if the session is gone/detached). + * H2/H3: a Claude Code hook reported activity for `sessionId`. Update the + * status, append a timeline event (A4) when `eventClass` is supplied and the + * timeline is enabled, and broadcast a `status` frame — carrying `gate` (B4) + * when the held approval is a 'tool'/'plan' gate. No-op for an unknown session. */ function handleHookEvent( sessionId: string, status: ClaudeStatus, detail?: string, pending?: boolean, + gate?: PermissionGate, + eventClass?: string, + toolName?: string, ): void { const session = sessions.get(sessionId); if (session === undefined) return; session.claudeStatus = status; - const msg: { type: 'status'; status: ClaudeStatus; detail?: string; pending?: boolean } = { - type: 'status', - status, - }; + + // A4: derive a bounded, sanitized timeline event from the raw hook name. + // makeTimelineEvent whitelist-gates the event class (unknown → null = drop). + if (cfg.timelineEnabled && eventClass !== undefined) { + const ev = makeTimelineEvent(eventClass, toolName, Date.now()); + if (ev !== null) { + session.timeline = appendEvent(session.timeline, ev, cfg.timelineMax); + } + } + + const msg: Extract = { type: 'status', status }; if (detail !== undefined) msg.detail = detail; if (pending) msg.pending = true; + if (gate !== undefined) msg.gate = gate; broadcast(session, msg); } + /** + * B2: store the latest statusLine telemetry for a session and broadcast a + * `telemetry` frame to every attached client. No-op for an unknown session. + */ + function handleStatusLine(id: string, telemetry: StatusTelemetry): void { + const session = sessions.get(id); + if (session === undefined) return; + session.telemetry = telemetry; + broadcast(session, { type: 'telemetry', telemetry }); + } + + /** + * A5 (H4): on the existing reaper tick (no new timer), mark live, non-idle + * sessions whose output has been silent past STUCK_TTL as 'stuck'. Covers BOTH + * attached and detached living sessions. Sets the status, broadcasts it (§3.4 + * review #5 — not just a notify), and pushes a single 'stuck' alert per silent + * round (re-armed by the next pty output in session.ts). Disabled when + * stuckAlert is off or stuckTtlMs ≤ 0. + */ + function sweepStuck(now: number): void { + if (!cfg.stuckAlert || cfg.stuckTtlMs <= 0) return; + + for (const session of sessions.values()) { + if (session.exitedAt !== null) continue; // already exited + if (session.claudeStatus === 'idle') continue; // legitimately done + if (session.stuckNotified) continue; // already alerted this round + if (now - session.lastOutputAt <= cfg.stuckTtlMs) continue; // still fresh + + session.claudeStatus = 'stuck'; + session.stuckNotified = true; + broadcast(session, { type: 'status', status: 'stuck' }); + void notifyService?.notify(session, 'stuck').catch((err: unknown) => { + console.error('[manager] stuck notification failed', err); + }); + } + } + /** * Reclaim detached sessions whose liveness window has expired (M3). * @@ -259,5 +331,15 @@ export function createSessionManager(cfg: Config): SessionManager { sessions = new Map(); } - return { handleAttach, get, list, killById, handleHookEvent, reapIdle, shutdown }; + return { + handleAttach, + get, + list, + killById, + handleHookEvent, + handleStatusLine, + sweepStuck, + reapIdle, + shutdown, + }; } diff --git a/src/session/session.ts b/src/session/session.ts index 25df498..416b508 100644 --- a/src/session/session.ts +++ b/src/session/session.ts @@ -32,6 +32,7 @@ import type { Session, SessionMeta, ServerMessage, + TimelineEvent, WebSocketLike, } from '../types.js'; import { WS_OPEN } from '../types.js'; @@ -95,6 +96,9 @@ export function createSession( ...process.env, WEBTERM_SESSION: id, WEBTERM_HOOK_URL: `http://127.0.0.1:${cfg.port}/hook`, + // B2: statusLine scripts POST telemetry here; WEBTERM_NTFY_* vars forwarded + // via ...process.env spread above (H3 — token set in env, not here). + WEBTERM_STATUSLINE_URL: `http://127.0.0.1:${cfg.port}/hook/status`, }, }); @@ -116,12 +120,25 @@ export function createSession( cwd: cwd ?? null, tmuxName: tName, pty, + // v0.7 Walk-away Workbench fields (T-spawn-env): + timeline: Object.freeze([] as TimelineEvent[]), + stuckNotified: false, // A5: re-armed to false by each pty output + telemetry: null, // B2: updated by manager.handleStatusLine }; // onData: persist to scrollback, refresh liveness, broadcast to all clients. pty.onData((chunk) => { session.buffer.append(chunk); session.lastOutputAt = Date.now(); + // A5 §3.4: re-arm the stuck flag on every output so each silent round can + // alert at most once. If we were stuck, recover to 'working' and broadcast + // so clients clear the stuck badge immediately — this is the ONLY reliable + // place to do this (same as lastOutputAt; T-manager never sees raw output). + session.stuckNotified = false; + if (session.claudeStatus === 'stuck') { + session.claudeStatus = 'working'; + broadcast(session, { type: 'status', status: 'working' }); + } broadcast(session, { type: 'output', data: chunk }); }); diff --git a/src/session/timeline.ts b/src/session/timeline.ts new file mode 100644 index 0000000..87835b7 --- /dev/null +++ b/src/session/timeline.ts @@ -0,0 +1,143 @@ +/** + * src/session/timeline.ts — N-timeline (A4) + * + * Pure functions for the bounded, timestamped activity ring. + * Zero DOM dependencies; importable from both backend and tests. + * + * Security: + * SEC-H6 — sanitizeField strips control chars and truncates tool names/paths + * SEC-M8 — appendEvent enforces the ring cap on every call + */ + +import type { TimelineClass, TimelineEvent } from '../types.js'; + +// ── constants ───────────────────────────────────────────────────────────────── + +/** Tools whose PostToolUse event gets an "edited" flavour label. */ +const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']); + +/** Whitelisted Claude Code hook event names. Events outside this set are + * dropped in makeTimelineEvent (unknown hooks = don't pollute the timeline). */ +const WHITELISTED_EVENTS = new Set([ + 'PreToolUse', + 'PostToolUse', + 'PermissionRequest', + 'Notification', + 'Stop', + 'SessionEnd', + 'UserPromptSubmit', +]); + +/** Maps hook event names to semantic TimelineClass values (§3.1 review #1). + * The FE consumes `class` directly for icon/colour — never re-derives. */ +const CLASS_MAP: Readonly> = { + PreToolUse: 'tool', + PostToolUse: 'tool', + PermissionRequest: 'waiting', + Notification: 'waiting', + Stop: 'done', + SessionEnd: 'done', + UserPromptSubmit: 'user', +}; + +// ── sanitizeField ───────────────────────────────────────────────────────────── + +/** + * Strip ASCII control characters (0x00–0x1f) from `s`, then truncate to `max` + * characters. Used for tool names and any other hook-supplied string fields. + * (SEC-H6) + */ +export function sanitizeField(s: string, max = 200): string { + // eslint-disable-next-line no-control-regex + return s.replace(/[\x00-\x1f]/g, '').slice(0, max); +} + +// ── deriveClass ─────────────────────────────────────────────────────────────── + +/** + * Map a raw Claude Code hook event name to its semantic TimelineClass (§3.1). + * Falls back to 'tool' for any event that doesn't appear in the map — + * makeTimelineEvent already whitelist-gates, so this is just a safety net. + */ +export function deriveClass(eventName: string): TimelineClass { + return CLASS_MAP[eventName] ?? 'tool'; +} + +// ── deriveLabel ─────────────────────────────────────────────────────────────── + +/** + * Produce a human-language label for a hook event. + * `toolName` is expected to already be sanitized when called from + * makeTimelineEvent; callers must not pass raw hook data here directly. + */ +export function deriveLabel(eventName: string, toolName?: string): string { + switch (eventName) { + case 'PreToolUse': + return toolName ? `using ${toolName}` : 'using tool'; + + case 'PostToolUse': + if (toolName && EDIT_TOOLS.has(toolName)) { + return `edited with ${toolName}`; + } + return toolName ? `ran ${toolName}` : 'ran tool'; + + case 'PermissionRequest': + case 'Notification': + return 'waiting for approval'; + + case 'Stop': + case 'SessionEnd': + return 'done'; + + case 'UserPromptSubmit': + return 'user input'; + + default: + return 'activity'; + } +} + +// ── makeTimelineEvent ───────────────────────────────────────────────────────── + +/** + * Build a TimelineEvent from raw hook data, or return null if the event name + * is not in the whitelist (unknown hooks are silently dropped). + * + * Sanitizes `toolName` via sanitizeField before embedding it in the event or + * passing it to deriveLabel (SEC-H6). + */ +export function makeTimelineEvent( + eventName: string, + toolName: string | undefined, + at: number, +): TimelineEvent | null { + if (!WHITELISTED_EVENTS.has(eventName)) return null; + + const sanitizedToolName = + toolName !== undefined ? sanitizeField(toolName) : undefined; + + return { + at, + class: deriveClass(eventName), + toolName: sanitizedToolName, + label: deriveLabel(eventName, sanitizedToolName), + }; +} + +// ── appendEvent ─────────────────────────────────────────────────────────────── + +/** + * Return a NEW array with `ev` appended to `events`, evicting the oldest + * entries if the length would exceed `maxLen`. Never mutates `events` (SEC-M8). + */ +export function appendEvent( + events: readonly TimelineEvent[], + ev: TimelineEvent, + maxLen: number, +): readonly TimelineEvent[] { + const next = [...events, ev]; + if (next.length > maxLen) { + return next.slice(next.length - maxLen); + } + return next; +} diff --git a/src/types.ts b/src/types.ts index 5baa540..8130c9b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -40,6 +40,35 @@ export interface Config { readonly projectScanTtlMs: number; // PROJECT_SCAN_TTL, default 10000 (discovery cache) readonly projectDirtyCheck: boolean; // PROJECT_DIRTY_CHECK, default true (git status per repo) readonly editorCmd: string; // EDITOR_CMD, default 'code' — POST /open-in-editor launches ` ` on the host + // v0.7 Walk-away Workbench — 21 new fields (impl: src/config.ts T-config) + // A1 push notifications (vapid* are SECRETS — never log/expose to clients) + readonly vapidPublicKey: string | undefined; // VAPID_PUBLIC_KEY + readonly vapidPrivateKey: string | undefined; // VAPID_PRIVATE_KEY (secret) + readonly vapidSubject: string | undefined; // VAPID_SUBJECT (mailto:/url) + readonly pushStorePath: string; // PUSH_STORE_PATH, default ~/.web-terminal-push-subs.json + readonly pushMaxSubs: number; // PUSH_MAX_SUBS, default 50 + readonly notifyDone: boolean; // NOTIFY_DONE, default true + readonly notifyDnd: boolean; // NOTIFY_DND, default false + readonly decisionTokenTtlMs: number; // DECISION_TOKEN_TTL_MS, default = permTimeoutMs + // A4 activity timeline + readonly timelineMax: number; // TIMELINE_MAX, default 200 + readonly timelineEnabled: boolean; // TIMELINE_ENABLED, default true + // A5 stuck detection + readonly stuckTtlMs: number; // STUCK_TTL (seconds env) → ms, default 600s + readonly stuckAlert: boolean; // STUCK_ALERT, default true + // B1 git diff + readonly diffTimeoutMs: number; // DIFF_TIMEOUT_MS, default 2000 + readonly diffMaxBytes: number; // DIFF_MAX_BYTES, default 2MB + readonly diffMaxFiles: number; // DIFF_MAX_FILES, default 300 + // B2 statusLine telemetry + readonly statuslineTtlMs: number; // STATUSLINE_TTL_MS, default 30000 + // B3 git worktree creation + readonly worktreeEnabled: boolean; // WORKTREE_ENABLED, default true + readonly worktreeRoot: string | undefined; // WORKTREE_ROOT (undefined → computed) + readonly worktreeTimeoutMs: number; // WORKTREE_TIMEOUT_MS, default 10000 + // B4 permission-mode relay + readonly defaultPermissionMode: PermissionMode; // DEFAULT_PERMISSION_MODE, default 'default' + readonly allowAutoMode: boolean; // ALLOW_AUTO_MODE, default false (SEC-M5) } /** process.env is structurally assignable to this; keeps the file free of `NodeJS.*`. */ @@ -51,26 +80,43 @@ export type EnvLike = Readonly>; /* ─────────────────────── protocol (§3.2) ─────────────────────── */ /** client → server. approve/reject (H3) resolve a held PermissionRequest. - * attach.cwd (M6) = spawn a new session in this directory ("new tab here"). */ + * attach.cwd (M6) = spawn a new session in this directory ("new tab here"). + * approve.mode (B4) = permission mode to write back when resolving a `plan` + * gate (e.g. approve+auto → 'acceptEdits'); omitted for ordinary tool gates. */ export type ClientMessage = | { type: 'attach'; sessionId: string | null; cwd?: string } | { type: 'input'; data: string } | { type: 'resize'; cols: number; rows: number } - | { type: 'approve' } + | { type: 'approve'; mode?: PermissionMode } | { type: 'reject' }; -/** Claude Code activity, derived from hooks (H2). 'unknown' = no hook signal yet. */ -export type ClaudeStatus = 'working' | 'waiting' | 'idle' | 'unknown'; +/** Claude Code activity, derived from hooks (H2). 'unknown' = no hook signal yet. + * 'stuck' (A5) = output silent past STUCK_TTL while not idle/exited; set by + * manager.sweepStuck and re-armed to 'working' on the next pty output. */ +export type ClaudeStatus = 'working' | 'waiting' | 'idle' | 'unknown' | 'stuck'; + +/** Which kind of held approval a `status` carries (B4). 'plan' = an ExitPlanMode + * gate (three-way approve/auto/keep-planning); 'tool' = an ordinary tool gate. */ +export type PermissionGate = 'tool' | 'plan'; /** server → client. exit.code = shell code; -1 when spawn never succeeded (M4). * exit.reason optional normally, REQUIRED on spawn failure / abnormal exit. * status (H2/H3) = Claude Code activity; `pending` true when a tool approval - * is held server-side and the client can approve/reject it. */ + * is held server-side and the client can approve/reject it; `gate` (B4) tells + * the client whether the held approval is a 'tool' or 'plan' gate. + * telemetry (B2) = latest statusLine telemetry broadcast for this session. */ export type ServerMessage = | { type: 'attached'; sessionId: string } | { type: 'output'; data: string } | { type: 'exit'; code: number; reason?: string } - | { type: 'status'; status: ClaudeStatus; detail?: string; pending?: boolean }; + | { + type: 'status'; + status: ClaudeStatus; + detail?: string; + pending?: boolean; + gate?: PermissionGate; + } + | { type: 'telemetry'; telemetry: StatusTelemetry }; /** parseClientMessage result — never throws; errors flow here (§5.3). */ export type ParseResult = @@ -172,6 +218,15 @@ export interface Session { cwd: string | null; /** tmux session name (H1) when running under tmux, else null. */ readonly tmuxName: string | null; + /** Bounded, timestamped activity ring (A4); newest appended, oldest evicted at + * cfg.timelineMax. Mutable runtime handle on the immutable meta (like buffer). + * Replaced wholesale via appendEvent (never mutated in place). */ + timeline: readonly TimelineEvent[]; + /** A5: true once a stuck alert fired this round; re-armed (→false) by the next + * pty.onData so each silent round alerts at most once. */ + stuckNotified: boolean; + /** B2: latest statusLine telemetry for this session; null until first report. */ + telemetry: StatusTelemetry | null; readonly pty: IPty; } @@ -196,6 +251,7 @@ export interface LiveSessionInfo { cwd: string | null; cols: number; // current PTY size (for the manage page) rows: number; + telemetry?: StatusTelemetry | null; // B2: latest telemetry for the thumbnail wall } /* ───────────────── project manager (v0.6, §4.3 FEATURE doc) ──────────────── */ @@ -262,20 +318,179 @@ export interface SessionManager { /** Kill a session by id (manage page): close its clients, kill the PTY, drop it. * Returns true if a session was found and killed. */ killById(id: string): boolean; - /** Set a session's Claude status (from a hook) and push it to the attached ws (H2/H3). */ + /** Set a session's Claude status (from a hook), append a timeline event (A4) + * when eventClass is supplied + timeline is enabled, and broadcast to all + * attached clients (H2/H3). gate (B4) marks a 'tool'/'plan' approval gate. */ handleHookEvent( sessionId: string, status: ClaudeStatus, detail?: string, pending?: boolean, + gate?: PermissionGate, + eventClass?: string, + toolName?: string, ): void; + /** B2: store the latest statusLine telemetry for a session and broadcast a + * `telemetry` message to all attached clients. */ + handleStatusLine(id: string, telemetry: StatusTelemetry): void; + /** A5: on the existing reaper tick (no new timer, H4), mark live non-idle + * sessions whose output has been silent past STUCK_TTL as 'stuck', broadcast, + * and notify once per silent round (re-armed by the next pty output). */ + sweepStuck(now: number): void; /** reclaim when now - max(detachedAt, lastOutputAt) > idleTtlMs (M3). Returns count. */ reapIdle(now: number): number; shutdown(): void; } // impl anchor [src/session/manager.ts]: -// export function createSessionManager(cfg: Config): SessionManager +// export function createSessionManager(cfg: Config, notifyService?: NotifyService): SessionManager +// notifyService is injected (DI, M5) so the manager never imports push-service +// directly (avoids a manager↔push-service cycle); sweepStuck uses it for 'stuck'. + +/* ───────────── v0.7 Walk-away Workbench (FEATURE_WALKAWAY_WORKBENCH) ───────────── + * + * The five FE↔BE contract resolutions from PLAN_WALKAWAY_WORKBENCH §3, frozen + * here so the frontend and backend agree on ONE spelling/shape. Field names for + * StatusTelemetry (B2) and the value set for PermissionMode (B4) are derived + * from the R0 statusLine / --permission-mode research findings. + */ + +/* ── B4 permission-mode relay (§3.5) ── */ + +/** --permission-mode values the relay exposes (R0-confirmed). 'auto' is its own + * Claude Code mode (background safety checks), distinct from bypassPermissions + * (the high-risk modes dontAsk/bypassPermissions are intentionally not surfaced). + * Validated at the WS boundary; gated server-side by ALLOW_AUTO_MODE (SEC-M5). */ +export type PermissionMode = 'default' | 'acceptEdits' | 'plan' | 'auto'; + +/* ── A1 push notifications (§3.3, §A1) ── */ + +/** The three proactive signals pushed to the phone (§3.3 / §A1). */ +export type NotifyClass = 'needs-input' | 'done' | 'stuck'; + +/** Outbound push body — ONE shape: push-service sends it, sw-push.js reads `cls` + * (§3.3 review #3). Minimal by design: no raw terminal output, no secrets. + * `token` is the per-decision capability token (only on needs-input). */ +export interface PushPayload { + sessionId: string; + toolName?: string; + detail?: string; + token?: string; + cls: NotifyClass; +} + +/** A stored browser PushSubscription (persisted to a 600-perm JSON, never via GET). */ +export interface PushSubscriptionRecord { + endpoint: string; + keys: { p256dh: string; auth: string }; + createdAt: number; +} + +/** The notification provider, injected into the manager (DI, M5) so it never + * imports push-service directly. isEnabled() is false when VAPID keys are unset + * (graceful disable). notify() honors DND/NOTIFY_DONE internally. */ +export interface NotifyService { + isEnabled(): boolean; + notify(session: Session, cls: NotifyClass, token?: string): Promise; +} + +/* ── B2 statusLine telemetry (§3.5, derived from R0 schema) ── */ + +/** Per-session telemetry derived server-side from the Claude Code statusLine + * stdin JSON (R0). All metric fields are optional — parsing is tolerant (missing + * field → undefined, never throw); `at` is the server receive time (Date.now()). + * Field mapping (R0 → here): context_window.used_percentage→contextUsedPct, + * cost.total_cost_usd→costUsd, cost.total_lines_added/removed→linesAdded/Removed, + * model.display_name→model, effort.level→effort, pr→pr, rate_limits→rate. */ +export interface StatusTelemetry { + contextUsedPct?: number; + costUsd?: number; + linesAdded?: number; + linesRemoved?: number; + model?: string; + effort?: string; + pr?: { number: number; url: string; reviewState?: string }; + rate?: { fiveHourPct?: number; sevenDayPct?: number }; + at: number; +} + +/* ── A4 activity timeline (§3.1) ── */ + +/** Server-DERIVED semantic class for a timeline event (review #1). The raw hook + * name is mapped by timeline.ts deriveClass(); the FE consumes `class` directly + * for icon/color and never re-derives from hook names. */ +export type TimelineClass = 'tool' | 'waiting' | 'done' | 'stuck' | 'user'; + +/** One bounded, timestamped activity entry (A4). `label` is the server-derived + * human phrase ("ran Bash", "edited 3 files"); `toolName` is sanitized (≤200, + * control chars stripped). `at` is the server Date.now() at ingest. */ +export interface TimelineEvent { + at: number; + class: TimelineClass; + toolName?: string; + label: string; +} + +/* ── B1 read-only git diff (§3.2) ── */ + +/** ONE spelling (semantic words, review #2). Parsing lives ONLY in the backend + * (src/http/diff.ts); public/diff.ts is render-only. */ +export type DiffLineKind = 'added' | 'removed' | 'context' | 'hunk' | 'meta'; + +export interface DiffLine { + kind: DiffLineKind; + text: string; +} + +export interface DiffHunk { + header: string; + lines: DiffLine[]; +} + +export type FileStatus = + | 'modified' + | 'added' + | 'deleted' + | 'renamed' + | 'binary' + | 'untracked'; + +export interface DiffFile { + oldPath: string; + newPath: string; + status: FileStatus; + added: number; + removed: number; + binary: boolean; + hunks: DiffHunk[]; +} + +export interface DiffResult { + files: DiffFile[]; + staged: boolean; + truncated: boolean; +} + +/* ── B3 worktree creation (§3.5) ── */ + +/** Result of POST /projects/worktree (B3). `error` carries a safe message only + * (never raw git stderr, SEC-M10); `status` is the HTTP status to return. */ +export interface CreateWorktreeResult { + ok: boolean; + path?: string; + branch?: string; + status?: number; + error?: string; +} + +/* ── GET /config/ui (review #4) ── */ + +/** Client-readable UI configuration (GET /config/ui). `allowAutoMode` mirrors + * the server ALLOW_AUTO_MODE gate so the FE can hide the high-risk 'auto' + * permission mode when the server forbids it (SEC-M5). */ +export interface UiConfig { + allowAutoMode: boolean; +} /* ─────────────────────── frontend (§5/§6.3) ──────────────────── */ diff --git a/test/config.test.ts b/test/config.test.ts index 4a7400c..cfe6202 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -445,3 +445,288 @@ describe('loadConfig — project discovery (v0.6)', () => { expect(loadConfig({ EDITOR_CMD: ' ' }).editorCmd).toBe('code') }) }) + +// ── v0.7 Walk-away Workbench — new env vars ─────────────────────────────────── +describe('loadConfig — v0.7 A1 push notifications', () => { + beforeEach(() => { + mockNetworkInterfaces.mockReturnValue({}) + mockHomedir.mockReturnValue('/home/testuser') + }) + + it('VAPID_PUBLIC_KEY/PRIVATE_KEY: unset → both undefined', () => { + const cfg = loadConfig({}) + expect(cfg.vapidPublicKey).toBeUndefined() + expect(cfg.vapidPrivateKey).toBeUndefined() + }) + + it('VAPID_PUBLIC_KEY/PRIVATE_KEY: reads from env', () => { + const cfg = loadConfig({ VAPID_PUBLIC_KEY: 'pubkey123', VAPID_PRIVATE_KEY: 'privkey456' }) + expect(cfg.vapidPublicKey).toBe('pubkey123') + expect(cfg.vapidPrivateKey).toBe('privkey456') + }) + + it('VAPID_SUBJECT: unset → undefined, reads from env', () => { + expect(loadConfig({}).vapidSubject).toBeUndefined() + expect(loadConfig({ VAPID_SUBJECT: 'mailto:admin@example.com' }).vapidSubject).toBe('mailto:admin@example.com') + }) + + it('PUSH_STORE_PATH: default is /.web-terminal-push-subs.json', () => { + const cfg = loadConfig({}) + expect(cfg.pushStorePath).toBe('/home/testuser/.web-terminal-push-subs.json') + }) + + it('PUSH_STORE_PATH: reads from env', () => { + const cfg = loadConfig({ PUSH_STORE_PATH: '/tmp/subs.json' }) + expect(cfg.pushStorePath).toBe('/tmp/subs.json') + }) + + it('PUSH_MAX_SUBS: default 50, reads from env', () => { + expect(loadConfig({}).pushMaxSubs).toBe(50) + expect(loadConfig({ PUSH_MAX_SUBS: '20' }).pushMaxSubs).toBe(20) + }) + + it('PUSH_MAX_SUBS: throws for non-integer', () => { + expect(() => loadConfig({ PUSH_MAX_SUBS: 'many' })).toThrow(/PUSH_MAX_SUBS/) + }) + + it('NOTIFY_DONE: default true (1), parses on/off', () => { + expect(loadConfig({}).notifyDone).toBe(true) + expect(loadConfig({ NOTIFY_DONE: '0' }).notifyDone).toBe(false) + expect(loadConfig({ NOTIFY_DONE: 'false' }).notifyDone).toBe(false) + expect(loadConfig({ NOTIFY_DONE: '1' }).notifyDone).toBe(true) + }) + + it('NOTIFY_DND: default false (0), parses on/off', () => { + expect(loadConfig({}).notifyDnd).toBe(false) + expect(loadConfig({ NOTIFY_DND: '1' }).notifyDnd).toBe(true) + expect(loadConfig({ NOTIFY_DND: 'on' }).notifyDnd).toBe(true) + expect(loadConfig({ NOTIFY_DND: '0' }).notifyDnd).toBe(false) + }) + + it('DECISION_TOKEN_TTL_MS: default = permTimeoutMs', () => { + const cfg = loadConfig({}) + expect(cfg.decisionTokenTtlMs).toBe(cfg.permTimeoutMs) + }) + + it('DECISION_TOKEN_TTL_MS: readable from env, overrides the default', () => { + const cfg = loadConfig({ DECISION_TOKEN_TTL_MS: '120000' }) + expect(cfg.decisionTokenTtlMs).toBe(120000) + }) + + it('DECISION_TOKEN_TTL_MS: throws for non-integer', () => { + expect(() => loadConfig({ DECISION_TOKEN_TTL_MS: 'never' })).toThrow(/DECISION_TOKEN_TTL_MS/) + }) +}) + +describe('loadConfig — v0.7 A4 timeline + A5 stuck detection', () => { + beforeEach(() => { + mockNetworkInterfaces.mockReturnValue({}) + mockHomedir.mockReturnValue('/home/testuser') + }) + + it('TIMELINE_MAX: default 200, reads from env', () => { + expect(loadConfig({}).timelineMax).toBe(200) + expect(loadConfig({ TIMELINE_MAX: '50' }).timelineMax).toBe(50) + }) + + it('TIMELINE_MAX: throws for non-integer', () => { + expect(() => loadConfig({ TIMELINE_MAX: 'lots' })).toThrow(/TIMELINE_MAX/) + }) + + it('TIMELINE_ENABLED: default true, parses on/off', () => { + expect(loadConfig({}).timelineEnabled).toBe(true) + expect(loadConfig({ TIMELINE_ENABLED: '0' }).timelineEnabled).toBe(false) + expect(loadConfig({ TIMELINE_ENABLED: 'false' }).timelineEnabled).toBe(false) + expect(loadConfig({ TIMELINE_ENABLED: '1' }).timelineEnabled).toBe(true) + }) + + it('STUCK_TTL: default 600000ms (600s), reads from env in seconds', () => { + expect(loadConfig({}).stuckTtlMs).toBe(600_000) + expect(loadConfig({ STUCK_TTL: '300' }).stuckTtlMs).toBe(300_000) + expect(loadConfig({ STUCK_TTL: '0' }).stuckTtlMs).toBe(0) + }) + + it('STUCK_TTL: throws for non-integer', () => { + expect(() => loadConfig({ STUCK_TTL: 'soon' })).toThrow(/STUCK_TTL/) + }) + + it('STUCK_ALERT: default true, parses on/off', () => { + expect(loadConfig({}).stuckAlert).toBe(true) + expect(loadConfig({ STUCK_ALERT: '0' }).stuckAlert).toBe(false) + expect(loadConfig({ STUCK_ALERT: 'off' }).stuckAlert).toBe(false) + expect(loadConfig({ STUCK_ALERT: '1' }).stuckAlert).toBe(true) + }) +}) + +describe('loadConfig — v0.7 B1 diff + B2 statusline', () => { + beforeEach(() => { + mockNetworkInterfaces.mockReturnValue({}) + mockHomedir.mockReturnValue('/home/testuser') + }) + + it('DIFF_TIMEOUT_MS: default 2000, reads from env', () => { + expect(loadConfig({}).diffTimeoutMs).toBe(2000) + expect(loadConfig({ DIFF_TIMEOUT_MS: '5000' }).diffTimeoutMs).toBe(5000) + }) + + it('DIFF_MAX_BYTES: default 2MB, reads from env', () => { + expect(loadConfig({}).diffMaxBytes).toBe(2 * 1024 * 1024) + expect(loadConfig({ DIFF_MAX_BYTES: '1048576' }).diffMaxBytes).toBe(1048576) + }) + + it('DIFF_MAX_FILES: default 300, reads from env', () => { + expect(loadConfig({}).diffMaxFiles).toBe(300) + expect(loadConfig({ DIFF_MAX_FILES: '100' }).diffMaxFiles).toBe(100) + }) + + it('DIFF_* vars throw for non-integer', () => { + expect(() => loadConfig({ DIFF_TIMEOUT_MS: 'fast' })).toThrow(/DIFF_TIMEOUT_MS/) + expect(() => loadConfig({ DIFF_MAX_BYTES: 'big' })).toThrow(/DIFF_MAX_BYTES/) + expect(() => loadConfig({ DIFF_MAX_FILES: 'many' })).toThrow(/DIFF_MAX_FILES/) + }) + + it('STATUSLINE_TTL_MS: default 30000, reads from env', () => { + expect(loadConfig({}).statuslineTtlMs).toBe(30_000) + expect(loadConfig({ STATUSLINE_TTL_MS: '60000' }).statuslineTtlMs).toBe(60_000) + }) + + it('STATUSLINE_TTL_MS: throws for non-integer', () => { + expect(() => loadConfig({ STATUSLINE_TTL_MS: 'long' })).toThrow(/STATUSLINE_TTL_MS/) + }) +}) + +describe('loadConfig — v0.7 B3 worktree', () => { + beforeEach(() => { + mockNetworkInterfaces.mockReturnValue({}) + mockHomedir.mockReturnValue('/home/testuser') + }) + + it('WORKTREE_ENABLED: default true, parses on/off', () => { + expect(loadConfig({}).worktreeEnabled).toBe(true) + expect(loadConfig({ WORKTREE_ENABLED: '0' }).worktreeEnabled).toBe(false) + expect(loadConfig({ WORKTREE_ENABLED: 'false' }).worktreeEnabled).toBe(false) + }) + + it('WORKTREE_ROOT: default undefined, reads from env', () => { + expect(loadConfig({}).worktreeRoot).toBeUndefined() + expect(loadConfig({ WORKTREE_ROOT: '/data/worktrees' }).worktreeRoot).toBe('/data/worktrees') + }) + + it('WORKTREE_TIMEOUT_MS: default 10000, reads from env', () => { + expect(loadConfig({}).worktreeTimeoutMs).toBe(10_000) + expect(loadConfig({ WORKTREE_TIMEOUT_MS: '20000' }).worktreeTimeoutMs).toBe(20_000) + }) + + it('WORKTREE_TIMEOUT_MS: throws for non-integer', () => { + expect(() => loadConfig({ WORKTREE_TIMEOUT_MS: 'slow' })).toThrow(/WORKTREE_TIMEOUT_MS/) + }) +}) + +describe('loadConfig — v0.7 B4 permission mode', () => { + beforeEach(() => { + mockNetworkInterfaces.mockReturnValue({}) + mockHomedir.mockReturnValue('/home/testuser') + }) + + it('DEFAULT_PERMISSION_MODE: default "default"', () => { + expect(loadConfig({}).defaultPermissionMode).toBe('default') + }) + + it('DEFAULT_PERMISSION_MODE: accepts all 4 valid values', () => { + expect(loadConfig({ DEFAULT_PERMISSION_MODE: 'default' }).defaultPermissionMode).toBe('default') + expect(loadConfig({ DEFAULT_PERMISSION_MODE: 'acceptEdits' }).defaultPermissionMode).toBe('acceptEdits') + expect(loadConfig({ DEFAULT_PERMISSION_MODE: 'plan' }).defaultPermissionMode).toBe('plan') + expect(loadConfig({ DEFAULT_PERMISSION_MODE: 'auto' }).defaultPermissionMode).toBe('auto') + }) + + it('DEFAULT_PERMISSION_MODE: throws for invalid values', () => { + expect(() => loadConfig({ DEFAULT_PERMISSION_MODE: 'bypassPermissions' })).toThrow(/DEFAULT_PERMISSION_MODE/) + expect(() => loadConfig({ DEFAULT_PERMISSION_MODE: 'dontAsk' })).toThrow(/DEFAULT_PERMISSION_MODE/) + expect(() => loadConfig({ DEFAULT_PERMISSION_MODE: '' })).toThrow(/DEFAULT_PERMISSION_MODE/) + }) + + it('ALLOW_AUTO_MODE: default false, parses on/off', () => { + expect(loadConfig({}).allowAutoMode).toBe(false) + expect(loadConfig({ ALLOW_AUTO_MODE: '1' }).allowAutoMode).toBe(true) + expect(loadConfig({ ALLOW_AUTO_MODE: 'true' }).allowAutoMode).toBe(true) + expect(loadConfig({ ALLOW_AUTO_MODE: '0' }).allowAutoMode).toBe(false) + }) +}) + +describe('loadConfig — v0.7 L5 permTimeoutMs validation', () => { + beforeEach(() => { + mockNetworkInterfaces.mockReturnValue({}) + mockHomedir.mockReturnValue('/home/testuser') + }) + + it('PERM_TIMEOUT_MS=0 throws (must be > 0 for --max-time integration)', () => { + expect(() => loadConfig({ PERM_TIMEOUT_MS: '0' })).toThrow(/PERM_TIMEOUT_MS/) + }) + + it('PERM_TIMEOUT_MS=1 is valid (positive integer)', () => { + expect(loadConfig({ PERM_TIMEOUT_MS: '1' }).permTimeoutMs).toBe(1) + }) +}) + +describe('loadConfig — v0.7 SEC-C5 VAPID key confidentiality', () => { + beforeEach(() => { + mockNetworkInterfaces.mockReturnValue({}) + mockHomedir.mockReturnValue('/home/testuser') + }) + + it('vapidPrivateKey field name is distinct (caller must avoid logging it)', () => { + // The private key must never appear in logs. Config stores the raw value + // under "vapidPrivateKey" — callers that log config should skip this field. + const cfg = loadConfig({ VAPID_PRIVATE_KEY: 'super-secret-key' }) + expect(cfg.vapidPrivateKey).toBe('super-secret-key') + // Verify the field name so callers can explicitly skip it in log output + expect(Object.keys(cfg)).toContain('vapidPrivateKey') + }) +}) + +describe('loadConfig — v0.7 all new fields present in frozen result', () => { + beforeEach(() => { + mockNetworkInterfaces.mockReturnValue({}) + mockHomedir.mockReturnValue('/home/testuser') + }) + + it('returned object is frozen and contains all 21 new v0.7 fields', () => { + const cfg = loadConfig({}) + expect(Object.isFrozen(cfg)).toBe(true) + + // A1 push + expect(cfg).toHaveProperty('vapidPublicKey') + expect(cfg).toHaveProperty('vapidPrivateKey') + expect(cfg).toHaveProperty('vapidSubject') + expect(cfg).toHaveProperty('pushStorePath') + expect(cfg).toHaveProperty('pushMaxSubs') + expect(cfg).toHaveProperty('notifyDone') + expect(cfg).toHaveProperty('notifyDnd') + expect(cfg).toHaveProperty('decisionTokenTtlMs') + + // A4 timeline + expect(cfg).toHaveProperty('timelineMax') + expect(cfg).toHaveProperty('timelineEnabled') + + // A5 stuck + expect(cfg).toHaveProperty('stuckTtlMs') + expect(cfg).toHaveProperty('stuckAlert') + + // B1 diff + expect(cfg).toHaveProperty('diffTimeoutMs') + expect(cfg).toHaveProperty('diffMaxBytes') + expect(cfg).toHaveProperty('diffMaxFiles') + + // B2 statusline + expect(cfg).toHaveProperty('statuslineTtlMs') + + // B3 worktree + expect(cfg).toHaveProperty('worktreeEnabled') + expect(cfg).toHaveProperty('worktreeRoot') + expect(cfg).toHaveProperty('worktreeTimeoutMs') + + // B4 permission mode + expect(cfg).toHaveProperty('defaultPermissionMode') + expect(cfg).toHaveProperty('allowAutoMode') + }) +}) diff --git a/test/diff.test.ts b/test/diff.test.ts new file mode 100644 index 0000000..3539d90 --- /dev/null +++ b/test/diff.test.ts @@ -0,0 +1,483 @@ +// @vitest-environment jsdom +/** + * test/diff.test.ts — N-diff-ui: diff viewer render-only frontend module + * + * Covers: normalizeDiffResult, fetchDiff, renderDiffFile, renderDiff, + * mountDiffViewer (working/staged toggle, truncated warning, empty state). + * + * Security: SEC-H4 — all diff content via textContent, zero innerHTML. + * Accept: AC-B1.3, AC-B1.4 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { DiffResult, DiffFile, DiffLine, DiffHunk } from '../src/types.js' + +// ── module import ───────────────────────────────────────────────────────────── +const { + normalizeDiffResult, + fetchDiff, + renderDiffFile, + renderDiff, + mountDiffViewer, +} = await import('../public/diff.js') + +// ── helpers ─────────────────────────────────────────────────────────────────── + +function makeLine(kind: DiffLine['kind'], text: string): DiffLine { + return { kind, text } +} + +function makeHunk(header: string, lines: DiffLine[] = []): DiffHunk { + return { header, lines } +} + +function makeFile(over: Partial = {}): DiffFile { + return { + oldPath: 'src/foo.ts', + newPath: 'src/foo.ts', + status: 'modified', + added: 2, + removed: 1, + binary: false, + hunks: [makeHunk('@@ -1,3 +1,4 @@', [ + makeLine('context', ' line1'), + makeLine('removed', '-old'), + makeLine('added', '+new'), + makeLine('added', '+extra'), + ])], + ...over, + } +} + +function makeResult(over: Partial = {}): DiffResult { + return { + files: [makeFile()], + staged: false, + truncated: false, + ...over, + } +} + +beforeEach(() => { + vi.restoreAllMocks() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +// ── normalizeDiffResult ─────────────────────────────────────────────────────── + +describe('normalizeDiffResult', () => { + it('returns null for null input', () => { + expect(normalizeDiffResult(null)).toBeNull() + }) + + it('returns null for non-object input', () => { + expect(normalizeDiffResult('string')).toBeNull() + expect(normalizeDiffResult(42)).toBeNull() + expect(normalizeDiffResult(undefined)).toBeNull() + }) + + it('returns null when files is not an array', () => { + expect(normalizeDiffResult({ files: 'bad', staged: false, truncated: false })).toBeNull() + }) + + it('returns null when staged is not a boolean', () => { + expect(normalizeDiffResult({ files: [], staged: 'yes', truncated: false })).toBeNull() + }) + + it('returns null when truncated is not a boolean', () => { + expect(normalizeDiffResult({ files: [], staged: false, truncated: 'yes' })).toBeNull() + }) + + it('returns a valid DiffResult for a well-formed object', () => { + const raw = makeResult() + const result = normalizeDiffResult(raw) + expect(result).not.toBeNull() + expect(result?.staged).toBe(false) + expect(result?.truncated).toBe(false) + expect(result?.files).toHaveLength(1) + }) + + it('filters out invalid file entries (keeps only valid ones)', () => { + const raw = { + files: [ + makeFile(), + { not: 'a file' }, + null, + ], + staged: false, + truncated: false, + } + const result = normalizeDiffResult(raw) + expect(result?.files).toHaveLength(1) + }) + + it('accepts an empty files array (empty diff)', () => { + const result = normalizeDiffResult({ files: [], staged: false, truncated: false }) + expect(result).not.toBeNull() + expect(result?.files).toHaveLength(0) + }) + + it('returns DiffResult with staged=true when input has staged=true', () => { + const result = normalizeDiffResult({ files: [], staged: true, truncated: false }) + expect(result?.staged).toBe(true) + }) + + it('returns DiffResult with truncated=true when input has truncated=true', () => { + const result = normalizeDiffResult({ files: [], staged: false, truncated: true }) + expect(result?.truncated).toBe(true) + }) +}) + +// ── fetchDiff ───────────────────────────────────────────────────────────────── + +describe('fetchDiff', () => { + it('fetches from /projects/diff with correct query params (working tree)', async () => { + const mockFetch = vi.fn(async () => ({ + ok: true, + json: async () => makeResult({ staged: false }), + })) + vi.stubGlobal('fetch', mockFetch) + + const result = await fetchDiff('/some/repo', false) + expect(mockFetch).toHaveBeenCalledOnce() + const url = mockFetch.mock.calls[0]?.[0] as string + expect(url).toContain('/projects/diff') + expect(url).toContain(encodeURIComponent('/some/repo')) + expect(url).toContain('staged=false') + expect(result).not.toBeNull() + }) + + it('fetches with staged=true when staged is true', async () => { + const mockFetch = vi.fn(async () => ({ + ok: true, + json: async () => makeResult({ staged: true }), + })) + vi.stubGlobal('fetch', mockFetch) + + await fetchDiff('/repo', true) + const url = mockFetch.mock.calls[0]?.[0] as string + expect(url).toContain('staged=true') + }) + + it('returns null on non-ok response', async () => { + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, json: async () => ({}) }))) + const result = await fetchDiff('/repo', false) + expect(result).toBeNull() + }) + + it('returns null on fetch error (network failure)', async () => { + vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('net') })) + const result = await fetchDiff('/repo', false) + expect(result).toBeNull() + }) + + it('returns null when server returns invalid structure', async () => { + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, + json: async () => ({ not: 'a diff result' }), + }))) + const result = await fetchDiff('/repo', false) + expect(result).toBeNull() + }) +}) + +// ── renderDiffFile ──────────────────────────────────────────────────────────── + +describe('renderDiffFile', () => { + it('renders the file path as plain text (newPath)', () => { + const file = makeFile({ newPath: 'src/component.ts' }) + const el = renderDiffFile(file) + expect(el.textContent).toContain('src/component.ts') + }) + + it('renders +/- stats badge with correct counts', () => { + const file = makeFile({ added: 5, removed: 3 }) + const el = renderDiffFile(file) + const text = el.textContent ?? '' + expect(text).toContain('+5') + expect(text).toContain('-3') + }) + + it('renders hunk header as text', () => { + const file = makeFile({ + hunks: [makeHunk('@@ -1,3 +1,4 @@', [])], + }) + const el = renderDiffFile(file) + expect(el.textContent).toContain('@@ -1,3 +1,4 @@') + }) + + it('renders added/removed/context lines as plain text (SEC-H4)', () => { + const file = makeFile({ + hunks: [makeHunk('@@ -1 +1 @@', [ + makeLine('added', '+new line'), + makeLine('removed', '-old line'), + makeLine('context', ' ctx line'), + ])], + }) + const el = renderDiffFile(file) + const text = el.textContent ?? '' + expect(text).toContain('+new line') + expect(text).toContain('-old line') + expect(text).toContain(' ctx line') + }) + + it('renders ' + const file = makeFile({ + hunks: [makeHunk('@@ -1 +1 @@', [ + makeLine('added', xssLine), + ])], + }) + const el = renderDiffFile(file) + // The text should be present + expect(el.textContent).toContain(xssLine) + // But no actual ')])], + })], + }) + const el = renderDiff(result) + expect(el.textContent).toContain('') + expect(el.querySelectorAll('script').length).toBe(0) + }) + + it('returns an HTMLElement', () => { + expect(renderDiff(makeResult())).toBeInstanceOf(HTMLElement) + }) +}) + +// ── mountDiffViewer ─────────────────────────────────────────────────────────── + +describe('mountDiffViewer', () => { + function makeContainer(): HTMLDivElement { + return document.createElement('div') + } + + it('renders into the container on mount', async () => { + const result = makeResult() + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, + json: async () => result, + }))) + + const container = makeContainer() + const handle = mountDiffViewer(container, '/repo', {}) + await new Promise((r) => setTimeout(r, 0)) // let async fetch settle + expect(container.children.length).toBeGreaterThan(0) + handle.destroy() + }) + + it('shows working tree by default (staged=false)', async () => { + const mockFetch = vi.fn(async () => ({ + ok: true, + json: async () => makeResult({ staged: false }), + })) + vi.stubGlobal('fetch', mockFetch) + + const container = makeContainer() + const handle = mountDiffViewer(container, '/repo', {}) + await new Promise((r) => setTimeout(r, 0)) + + const url = mockFetch.mock.calls[0]?.[0] as string + expect(url).toContain('staged=false') + handle.destroy() + }) + + it('can switch to staged view', async () => { + const mockFetch = vi.fn(async () => ({ + ok: true, + json: async () => makeResult({ staged: true }), + })) + vi.stubGlobal('fetch', mockFetch) + + const container = makeContainer() + const handle = mountDiffViewer(container, '/repo', {}) + await new Promise((r) => setTimeout(r, 0)) + + // Switch to staged + handle.showStaged() + await new Promise((r) => setTimeout(r, 0)) + + // Should have fetched twice: once working, once staged + expect(mockFetch).toHaveBeenCalledTimes(2) + const secondUrl = mockFetch.mock.calls[1]?.[0] as string + expect(secondUrl).toContain('staged=true') + handle.destroy() + }) + + it('can switch back to working view', async () => { + const mockFetch = vi.fn(async () => ({ + ok: true, + json: async () => makeResult(), + })) + vi.stubGlobal('fetch', mockFetch) + + const container = makeContainer() + const handle = mountDiffViewer(container, '/repo', {}) + await new Promise((r) => setTimeout(r, 0)) + handle.showStaged() + await new Promise((r) => setTimeout(r, 0)) + handle.showWorking() + await new Promise((r) => setTimeout(r, 0)) + + expect(mockFetch).toHaveBeenCalledTimes(3) + const thirdUrl = mockFetch.mock.calls[2]?.[0] as string + expect(thirdUrl).toContain('staged=false') + handle.destroy() + }) + + it('calls onClose when the close action fires', async () => { + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, + json: async () => makeResult(), + }))) + + const onClose = vi.fn() + const container = makeContainer() + const handle = mountDiffViewer(container, '/repo', { onClose }) + await new Promise((r) => setTimeout(r, 0)) + + handle.close() + expect(onClose).toHaveBeenCalledOnce() + handle.destroy() + }) + + it('shows error/empty state when fetch fails (does not throw)', async () => { + vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('net') })) + + const container = makeContainer() + const handle = mountDiffViewer(container, '/repo', {}) + await new Promise((r) => setTimeout(r, 0)) + + // Should not throw and should render something + expect(container.children.length).toBeGreaterThan(0) + handle.destroy() + }) + + it('destroy removes content from container', async () => { + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, + json: async () => makeResult(), + }))) + + const container = makeContainer() + const handle = mountDiffViewer(container, '/repo', {}) + await new Promise((r) => setTimeout(r, 0)) + handle.destroy() + expect(container.children.length).toBe(0) + }) + + it('handles empty diff gracefully (no throw)', async () => { + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, + json: async () => makeResult({ files: [] }), + }))) + + const container = makeContainer() + const handle = mountDiffViewer(container, '/some/repo', {}) + await expect(new Promise((r) => setTimeout(r, 0))).resolves.toBeUndefined() + handle.destroy() + }) +}) diff --git a/test/hook.test.ts b/test/hook.test.ts index daa8ac6..1b2e70b 100644 --- a/test/hook.test.ts +++ b/test/hook.test.ts @@ -1,42 +1,54 @@ import { describe, it, expect } from 'vitest' import { parseHookEvent } from '../src/http/hook.js' +import type { HookEventFull } from '../src/http/hook.js' const SID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479' -describe('parseHookEvent', () => { - it('maps tool events to "working"', () => { +// ── Existing status / detail behaviour (must not regress) ─────────────────── +// We use expect.objectContaining() so the new fields (at/eventClass/gate) do +// not break these assertion — they verify only the status-mapping contract. + +describe('parseHookEvent — status mapping (regression guard)', () => { + it('maps PreToolUse / PostToolUse / UserPromptSubmit to "working"', () => { for (const e of ['PreToolUse', 'PostToolUse', 'UserPromptSubmit']) { - expect(parseHookEvent(SID, { hook_event_name: e })).toEqual({ - sessionId: SID, - status: 'working', - }) + expect(parseHookEvent(SID, { hook_event_name: e })).toEqual( + expect.objectContaining({ sessionId: SID, status: 'working' }), + ) } }) - it('maps PermissionRequest to "waiting" with the tool name as detail', () => { - expect(parseHookEvent(SID, { hook_event_name: 'PermissionRequest', tool_name: 'Bash' })).toEqual( - { sessionId: SID, status: 'waiting', detail: 'Bash' }, - ) + it('maps PermissionRequest to "waiting" with tool_name as detail', () => { + expect( + parseHookEvent(SID, { hook_event_name: 'PermissionRequest', tool_name: 'Bash' }), + ).toEqual(expect.objectContaining({ sessionId: SID, status: 'waiting', detail: 'Bash' })) }) - it('maps Notification permission_prompt to "waiting", idle_prompt to "idle"', () => { + it('maps Notification(permission_prompt) → "waiting"; other Notification → "idle"', () => { expect( - parseHookEvent(SID, { hook_event_name: 'Notification', notification_type: 'permission_prompt' }), - ).toEqual({ sessionId: SID, status: 'waiting' }) + parseHookEvent(SID, { + hook_event_name: 'Notification', + notification_type: 'permission_prompt', + }), + ).toEqual(expect.objectContaining({ sessionId: SID, status: 'waiting' })) + expect( - parseHookEvent(SID, { hook_event_name: 'Notification', notification_type: 'idle_prompt' }), - ).toEqual({ sessionId: SID, status: 'idle' }) + parseHookEvent(SID, { + hook_event_name: 'Notification', + notification_type: 'idle_prompt', + }), + ).toEqual(expect.objectContaining({ sessionId: SID, status: 'idle' })) }) it('maps Stop / SessionEnd to "idle"', () => { - expect(parseHookEvent(SID, { hook_event_name: 'Stop' })).toEqual({ sessionId: SID, status: 'idle' }) - expect(parseHookEvent(SID, { hook_event_name: 'SessionEnd' })).toEqual({ - sessionId: SID, - status: 'idle', - }) + expect(parseHookEvent(SID, { hook_event_name: 'Stop' })).toEqual( + expect.objectContaining({ sessionId: SID, status: 'idle' }), + ) + expect(parseHookEvent(SID, { hook_event_name: 'SessionEnd' })).toEqual( + expect.objectContaining({ sessionId: SID, status: 'idle' }), + ) }) - it('returns null for missing sessionId, bad body, or unknown event', () => { + it('returns null for missing/empty sessionId, bad body, or unknown event', () => { expect(parseHookEvent(undefined, { hook_event_name: 'Stop' })).toBeNull() expect(parseHookEvent('', { hook_event_name: 'Stop' })).toBeNull() expect(parseHookEvent(SID, null)).toBeNull() @@ -45,3 +57,161 @@ describe('parseHookEvent', () => { expect(parseHookEvent(SID, {})).toBeNull() }) }) + +// ── HookEventFull — new A4 / B4 fields ────────────────────────────────────── + +describe('HookEventFull — at field (A4)', () => { + it('includes a numeric timestamp in [before, after] around the call', () => { + const before = Date.now() + const ev = parseHookEvent(SID, { hook_event_name: 'Stop' }) as HookEventFull + const after = Date.now() + + expect(ev).not.toBeNull() + expect(typeof ev.at).toBe('number') + expect(ev.at).toBeGreaterThanOrEqual(before) + expect(ev.at).toBeLessThanOrEqual(after) + }) + + it('is absent from null results', () => { + const ev = parseHookEvent(SID, { hook_event_name: 'Unknown' }) + expect(ev).toBeNull() + }) +}) + +describe('HookEventFull — eventClass field (A4)', () => { + const EVENTS = [ + 'PreToolUse', + 'PostToolUse', + 'UserPromptSubmit', + 'PermissionRequest', + 'Stop', + 'SessionEnd', + 'Notification', + ] + + it('carries the raw hook_event_name for every whitelisted event', () => { + for (const e of EVENTS) { + const ev = parseHookEvent(SID, { hook_event_name: e }) as HookEventFull + expect(ev).not.toBeNull() + expect(ev.eventClass).toBe(e) + } + }) + + it('is not present when parseHookEvent returns null', () => { + expect(parseHookEvent(SID, { hook_event_name: 'NotAHook' })).toBeNull() + }) +}) + +describe('HookEventFull — toolInput passthrough (A4)', () => { + it('includes toolInput when tool_input is present in the body', () => { + const input = { path: '/src/foo.ts', content: 'hello world' } + const ev = parseHookEvent(SID, { + hook_event_name: 'PreToolUse', + tool_input: input, + }) as HookEventFull + + expect(ev).not.toBeNull() + expect(ev.toolInput).toBe(input) // same reference, no copy/parse + }) + + it('passes toolInput through as-is (does not validate inner shape)', () => { + const ev = parseHookEvent(SID, { + hook_event_name: 'PostToolUse', + tool_input: 'just a string', + }) as HookEventFull + + expect(ev.toolInput).toBe('just a string') + }) + + it('omits toolInput when tool_input is absent', () => { + const ev = parseHookEvent(SID, { hook_event_name: 'PreToolUse' }) as HookEventFull + expect(ev).not.toBeNull() + expect('toolInput' in ev).toBe(false) + }) + + it('includes toolInput even when its value is null', () => { + const ev = parseHookEvent(SID, { + hook_event_name: 'PostToolUse', + tool_input: null, + }) as HookEventFull + expect(ev).not.toBeNull() + expect('toolInput' in ev).toBe(true) + expect(ev.toolInput).toBeNull() + }) +}) + +describe('HookEventFull — gate field (B4 plan-gate recognition)', () => { + it('sets gate="plan" for PermissionRequest with ExitPlanMode tool', () => { + const ev = parseHookEvent(SID, { + hook_event_name: 'PermissionRequest', + tool_name: 'ExitPlanMode', + }) as HookEventFull + + expect(ev).not.toBeNull() + expect(ev.gate).toBe('plan') + }) + + it('sets gate="tool" for PermissionRequest with any other tool name', () => { + const ev = parseHookEvent(SID, { + hook_event_name: 'PermissionRequest', + tool_name: 'Bash', + }) as HookEventFull + + expect(ev).not.toBeNull() + expect(ev.gate).toBe('tool') + }) + + it('sets gate="tool" for PermissionRequest with no tool_name', () => { + const ev = parseHookEvent(SID, { hook_event_name: 'PermissionRequest' }) as HookEventFull + + expect(ev).not.toBeNull() + expect(ev.gate).toBe('tool') + }) + + it('omits gate for non-PermissionRequest events', () => { + const nonPermEvents = [ + 'PreToolUse', + 'PostToolUse', + 'UserPromptSubmit', + 'Stop', + 'SessionEnd', + 'Notification', + ] + for (const e of nonPermEvents) { + const ev = parseHookEvent(SID, { hook_event_name: e }) as HookEventFull + expect(ev).not.toBeNull() + expect('gate' in ev).toBe(false) + } + }) +}) + +describe('HookEventFull — detail not set for Notification (no tool_name)', () => { + it('Notification(permission_prompt) has status=waiting but no detail', () => { + const ev = parseHookEvent(SID, { + hook_event_name: 'Notification', + notification_type: 'permission_prompt', + }) as HookEventFull + + expect(ev).not.toBeNull() + expect(ev.status).toBe('waiting') + expect('detail' in ev).toBe(false) + }) +}) + +describe('SEC-M7 — never throws on any body shape', () => { + const malformed = [ + [], + 42, + true, + undefined, + { hook_event_name: null }, + { hook_event_name: 123 }, + { hook_event_name: 'PreToolUse', tool_input: { toString: null } }, + ] + + it('returns null without throwing for every malformed body', () => { + for (const b of malformed) { + expect(() => parseHookEvent(SID, b)).not.toThrow() + } + }) +}) diff --git a/test/http/diff.test.ts b/test/http/diff.test.ts new file mode 100644 index 0000000..3ad9112 --- /dev/null +++ b/test/http/diff.test.ts @@ -0,0 +1,348 @@ +/** + * test/http/diff.test.ts (N-diff-be, B1) — read-only git diff parsing + getDiff. + * + * Two layers: + * 1. Pure parsers (parseNumstat / parseUnifiedDiff) fed canned git output — the + * deterministic core (AC-B1.5: rename/new/delete/binary/empty/untracked, +/- + * consistent with `git diff --numstat`; never throws; `cmd` red' + const patch = [ + 'diff --git a/x.txt b/x.txt', + '--- a/x.txt', + '+++ b/x.txt', + '@@ -0,0 +1 @@', + '+' + payload, + '', + ].join('\n') + const [file] = parseUnifiedDiff(patch) + expect(file?.hunks[0]?.lines[0]).toEqual({ kind: 'added', text: payload }) + }) + + it('parses several files in one patch', () => { + const patch = MODIFIED + [ + 'diff --git a/bar.txt b/bar.txt', + '--- a/bar.txt', + '+++ b/bar.txt', + '@@ -1 +1 @@', + '-one', + '+two', + '', + ].join('\n') + const files = parseUnifiedDiff(patch) + expect(files.map((f) => f.newPath)).toEqual(['foo.txt', 'bar.txt']) + }) +}) + +// ── getDiff (integration against a real git repo) ──────────────────────────── + +async function git(cwd: string, ...args: string[]): Promise { + await execFileAsync('git', args, { cwd }) +} + +describe('getDiff (real git repo)', () => { + let repo: string + + beforeAll(async () => { + repo = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-diff-')) + await git(repo, 'init', '-q', '-b', 'main') + await git(repo, 'config', 'user.email', 'test@example.com') + await git(repo, 'config', 'user.name', 'Test') + await git(repo, 'config', 'commit.gpgsign', 'false') + await fs.writeFile(path.join(repo, 'tracked.txt'), 'one\ntwo\nthree\n') + await git(repo, 'add', '.') + await git(repo, 'commit', '-q', '-m', 'init') + }) + + afterAll(async () => { + await fs.rm(repo, { recursive: true, force: true }) + }) + + it('returns structured working-tree diff with numstat-consistent counts', async () => { + await fs.writeFile(path.join(repo, 'tracked.txt'), 'one\nTWO\nthree\nfour\n') + const result = await getDiff(repo, { staged: false, cfg: LIMITS }) + expect(result.staged).toBe(false) + expect(result.truncated).toBe(false) + const file = result.files.find((f) => f.newPath === 'tracked.txt') + expect(file).toBeDefined() + expect(file?.status).toBe('modified') + // changed line two + added line four = 2 added, 1 removed + expect(file?.added).toBe(2) + expect(file?.removed).toBe(1) + // revert for later tests + await fs.writeFile(path.join(repo, 'tracked.txt'), 'one\ntwo\nthree\n') + }) + + it('lists untracked files via porcelain (L3, working tree only)', async () => { + await fs.writeFile(path.join(repo, 'fresh.txt'), 'brand new\n') + const result = await getDiff(repo, { staged: false, cfg: LIMITS }) + const untracked = result.files.find((f) => f.newPath === 'fresh.txt') + expect(untracked?.status).toBe('untracked') + await fs.rm(path.join(repo, 'fresh.txt')) + }) + + it('does not include untracked files in the staged diff', async () => { + await fs.writeFile(path.join(repo, 'staged-only.txt'), 'queued\n') + await git(repo, 'add', 'staged-only.txt') + const result = await getDiff(repo, { staged: true, cfg: LIMITS }) + expect(result.staged).toBe(true) + const file = result.files.find((f) => f.newPath === 'staged-only.txt') + expect(file?.status).toBe('added') + expect(result.files.every((f) => f.status !== 'untracked')).toBe(true) + await git(repo, 'reset', '-q') + await fs.rm(path.join(repo, 'staged-only.txt')) + }) + + it('marks truncated when the file count exceeds diffMaxFiles', async () => { + for (let i = 0; i < 5; i++) { + await fs.writeFile(path.join(repo, `gen-${i}.txt`), `content ${i}\n`) + } + const result = await getDiff(repo, { + staged: false, + cfg: { ...LIMITS, diffMaxFiles: 2 }, + }) + expect(result.truncated).toBe(true) + expect(result.files.length).toBeLessThanOrEqual(2) + for (let i = 0; i < 5; i++) await fs.rm(path.join(repo, `gen-${i}.txt`)) + }) + + it('never throws for a non-existent / non-git path (returns empty)', async () => { + const result = await getDiff(path.join(os.tmpdir(), 'definitely-not-here-xyz'), { + staged: false, + cfg: LIMITS, + }) + expect(result.files).toEqual([]) + expect(result.truncated).toBe(false) + }) +}) diff --git a/test/http/statusline.test.ts b/test/http/statusline.test.ts new file mode 100644 index 0000000..be9609c --- /dev/null +++ b/test/http/statusline.test.ts @@ -0,0 +1,373 @@ +/** + * test/http/statusline.test.ts — TDD tests for parseStatusLine (N-statusline-be). + * + * Acceptance: AC-B2.6 — full/missing/dirty/empty bodies; never throw; ≥80% coverage. + * Security: SEC-M7 — unknown + narrowing, string length limits. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { parseStatusLine } from '../../src/http/statusline.js' +import type { StatusTelemetry } from '../../src/types.js' + +// Representative full Claude Code statusLine JSON body. +// Field mapping (R0 → StatusTelemetry): context_window.used_percentage→contextUsedPct, +// cost.total_cost_usd→costUsd, cost.total_lines_added/removed→linesAdded/Removed, +// model.display_name→model, effort.level→effort, pr→pr, rate_limits→rate. +const FULL_BODY = { + context_window: { used_percentage: 42.5 }, + cost: { total_cost_usd: 0.05, total_lines_added: 100, total_lines_removed: 50 }, + model: { display_name: 'Claude 3.5 Sonnet' }, + effort: { level: 'normal' }, + pr: { number: 42, url: 'https://github.com/foo/bar/pull/42', reviewState: 'pending' }, + rate_limits: { fiveHourPct: 10, sevenDayPct: 20 }, +} + +const FIXED_NOW = 1_700_000_000_000 + +describe('parseStatusLine — null for non-objects', () => { + it('returns null for null', () => { + expect(parseStatusLine(null)).toBeNull() + }) + + it('returns null for undefined', () => { + expect(parseStatusLine(undefined)).toBeNull() + }) + + it('returns null for a string', () => { + expect(parseStatusLine('hello')).toBeNull() + }) + + it('returns null for a number', () => { + expect(parseStatusLine(42)).toBeNull() + }) + + it('returns null for a boolean', () => { + expect(parseStatusLine(true)).toBeNull() + }) + + it('returns null for an array (typeof is "object" but is not a plain object)', () => { + expect(parseStatusLine([])).toBeNull() + expect(parseStatusLine([1, 2, 3])).toBeNull() + }) +}) + +describe('parseStatusLine — full valid body', () => { + beforeEach(() => { + vi.spyOn(Date, 'now').mockReturnValue(FIXED_NOW) + }) + afterEach(() => { + vi.restoreAllMocks() + }) + + it('maps all fields correctly from a fully-populated body', () => { + const result = parseStatusLine(FULL_BODY) + + expect(result).not.toBeNull() + const t = result as StatusTelemetry + + expect(t.contextUsedPct).toBe(42.5) + expect(t.costUsd).toBe(0.05) + expect(t.linesAdded).toBe(100) + expect(t.linesRemoved).toBe(50) + expect(t.model).toBe('Claude 3.5 Sonnet') + expect(t.effort).toBe('normal') + expect(t.pr).toEqual({ number: 42, url: 'https://github.com/foo/bar/pull/42', reviewState: 'pending' }) + expect(t.rate).toEqual({ fiveHourPct: 10, sevenDayPct: 20 }) + expect(t.at).toBe(FIXED_NOW) + }) +}) + +describe('parseStatusLine — empty/minimal body', () => { + beforeEach(() => { + vi.spyOn(Date, 'now').mockReturnValue(FIXED_NOW) + }) + afterEach(() => { + vi.restoreAllMocks() + }) + + it('returns a StatusTelemetry with only at for an empty object', () => { + const result = parseStatusLine({}) + + expect(result).not.toBeNull() + expect(result?.at).toBe(FIXED_NOW) + expect(result?.contextUsedPct).toBeUndefined() + expect(result?.costUsd).toBeUndefined() + expect(result?.linesAdded).toBeUndefined() + expect(result?.linesRemoved).toBeUndefined() + expect(result?.model).toBeUndefined() + expect(result?.effort).toBeUndefined() + expect(result?.pr).toBeUndefined() + expect(result?.rate).toBeUndefined() + }) +}) + +describe('parseStatusLine — at timestamp', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('sets at to Date.now() on each call', () => { + const t1 = 1000 + const t2 = 2000 + vi.spyOn(Date, 'now').mockReturnValueOnce(t1).mockReturnValueOnce(t2) + + expect(parseStatusLine({})?.at).toBe(t1) + expect(parseStatusLine({})?.at).toBe(t2) + }) +}) + +describe('parseStatusLine — dirty number fields', () => { + it('omits contextUsedPct when used_percentage is NaN', () => { + expect( + parseStatusLine({ context_window: { used_percentage: NaN } })?.contextUsedPct, + ).toBeUndefined() + }) + + it('omits contextUsedPct when used_percentage is Infinity', () => { + expect( + parseStatusLine({ context_window: { used_percentage: Infinity } })?.contextUsedPct, + ).toBeUndefined() + }) + + it('omits contextUsedPct when used_percentage is a string', () => { + expect( + parseStatusLine({ context_window: { used_percentage: '42' } })?.contextUsedPct, + ).toBeUndefined() + }) + + it('omits costUsd when total_cost_usd is Infinity', () => { + expect(parseStatusLine({ cost: { total_cost_usd: Infinity } })?.costUsd).toBeUndefined() + }) + + it('omits costUsd when total_cost_usd is NaN', () => { + expect(parseStatusLine({ cost: { total_cost_usd: NaN } })?.costUsd).toBeUndefined() + }) + + it('omits linesAdded when total_lines_added is a string', () => { + expect(parseStatusLine({ cost: { total_lines_added: 'bad' } })?.linesAdded).toBeUndefined() + }) + + it('omits linesRemoved when total_lines_removed is null', () => { + expect(parseStatusLine({ cost: { total_lines_removed: null } })?.linesRemoved).toBeUndefined() + }) + + it('parses partial cost fields independently', () => { + const result = parseStatusLine({ cost: { total_cost_usd: 1.5 } }) + + expect(result?.costUsd).toBe(1.5) + expect(result?.linesAdded).toBeUndefined() + expect(result?.linesRemoved).toBeUndefined() + }) +}) + +describe('parseStatusLine — context_window not an object', () => { + it('omits contextUsedPct when context_window is a string', () => { + expect(parseStatusLine({ context_window: 'bad' })?.contextUsedPct).toBeUndefined() + }) + + it('omits contextUsedPct when context_window is null', () => { + expect(parseStatusLine({ context_window: null })?.contextUsedPct).toBeUndefined() + }) + + it('omits contextUsedPct when context_window is a number', () => { + expect(parseStatusLine({ context_window: 42 })?.contextUsedPct).toBeUndefined() + }) +}) + +describe('parseStatusLine — cost not an object', () => { + it('omits all cost fields when cost is a string', () => { + const result = parseStatusLine({ cost: 'bad' }) + + expect(result?.costUsd).toBeUndefined() + expect(result?.linesAdded).toBeUndefined() + expect(result?.linesRemoved).toBeUndefined() + }) + + it('omits all cost fields when cost is null', () => { + const result = parseStatusLine({ cost: null }) + + expect(result?.costUsd).toBeUndefined() + expect(result?.linesAdded).toBeUndefined() + expect(result?.linesRemoved).toBeUndefined() + }) +}) + +describe('parseStatusLine — dirty string fields (SEC-M7 length caps)', () => { + it('omits model when display_name exceeds 200 characters', () => { + expect( + parseStatusLine({ model: { display_name: 'a'.repeat(201) } })?.model, + ).toBeUndefined() + }) + + it('accepts model at exactly 200 characters', () => { + const model200 = 'a'.repeat(200) + expect(parseStatusLine({ model: { display_name: model200 } })?.model).toBe(model200) + }) + + it('omits model when model field is not an object', () => { + expect(parseStatusLine({ model: 'bad' })?.model).toBeUndefined() + expect(parseStatusLine({ model: null })?.model).toBeUndefined() + expect(parseStatusLine({ model: 42 })?.model).toBeUndefined() + }) + + it('omits effort when level is a number', () => { + expect(parseStatusLine({ effort: { level: 42 } })?.effort).toBeUndefined() + }) + + it('omits effort when level exceeds 100 characters', () => { + expect( + parseStatusLine({ effort: { level: 'x'.repeat(101) } })?.effort, + ).toBeUndefined() + }) + + it('accepts effort at exactly 100 characters', () => { + const effort100 = 'e'.repeat(100) + expect(parseStatusLine({ effort: { level: effort100 } })?.effort).toBe(effort100) + }) + + it('omits effort when effort field is not an object', () => { + expect(parseStatusLine({ effort: 42 })?.effort).toBeUndefined() + expect(parseStatusLine({ effort: null })?.effort).toBeUndefined() + expect(parseStatusLine({ effort: [] })?.effort).toBeUndefined() + }) +}) + +describe('parseStatusLine — pr parsing', () => { + it('parses pr with all fields including optional reviewState', () => { + const result = parseStatusLine({ + pr: { number: 42, url: 'https://github.com/foo/bar/pull/42', reviewState: 'pending' }, + }) + + expect(result?.pr).toEqual({ + number: 42, + url: 'https://github.com/foo/bar/pull/42', + reviewState: 'pending', + }) + }) + + it('parses pr without optional reviewState', () => { + const result = parseStatusLine({ pr: { number: 5, url: 'https://github.com/pr/5' } }) + + expect(result?.pr).toBeDefined() + expect(result?.pr?.number).toBe(5) + expect(result?.pr?.url).toBe('https://github.com/pr/5') + expect(result?.pr?.reviewState).toBeUndefined() + }) + + it('omits pr when pr.number is missing', () => { + expect(parseStatusLine({ pr: { url: 'https://example.com' } })?.pr).toBeUndefined() + }) + + it('omits pr when pr.url is missing', () => { + expect(parseStatusLine({ pr: { number: 1 } })?.pr).toBeUndefined() + }) + + it('omits pr when both required fields are missing', () => { + expect(parseStatusLine({ pr: {} })?.pr).toBeUndefined() + }) + + it('omits pr when pr is not an object', () => { + expect(parseStatusLine({ pr: 'not-an-object' })?.pr).toBeUndefined() + expect(parseStatusLine({ pr: null })?.pr).toBeUndefined() + expect(parseStatusLine({ pr: 42 })?.pr).toBeUndefined() + }) + + it('omits pr when pr.url exceeds 2000 characters', () => { + const longUrl = 'https://example.com/' + 'x'.repeat(1990) + expect(parseStatusLine({ pr: { number: 1, url: longUrl } })?.pr).toBeUndefined() + }) + + it('omits pr.reviewState but keeps pr when reviewState exceeds 100 characters', () => { + const result = parseStatusLine({ + pr: { + number: 1, + url: 'https://github.com/pr/1', + reviewState: 'x'.repeat(101), + }, + }) + + expect(result?.pr).toBeDefined() + expect(result?.pr?.number).toBe(1) + expect(result?.pr?.url).toBe('https://github.com/pr/1') + expect(result?.pr?.reviewState).toBeUndefined() + }) + + it('omits pr when pr.number is NaN', () => { + expect(parseStatusLine({ pr: { number: NaN, url: 'https://github.com' } })?.pr).toBeUndefined() + }) +}) + +describe('parseStatusLine — rate_limits parsing', () => { + it('parses rate_limits with both fields', () => { + const result = parseStatusLine({ rate_limits: { fiveHourPct: 25, sevenDayPct: 50 } }) + + expect(result?.rate).toEqual({ fiveHourPct: 25, sevenDayPct: 50 }) + }) + + it('parses rate_limits with only fiveHourPct', () => { + const result = parseStatusLine({ rate_limits: { fiveHourPct: 25 } }) + + expect(result?.rate?.fiveHourPct).toBe(25) + expect(result?.rate?.sevenDayPct).toBeUndefined() + }) + + it('parses rate_limits with only sevenDayPct', () => { + const result = parseStatusLine({ rate_limits: { sevenDayPct: 75 } }) + + expect(result?.rate?.fiveHourPct).toBeUndefined() + expect(result?.rate?.sevenDayPct).toBe(75) + }) + + it('omits rate when rate_limits is not an object', () => { + expect(parseStatusLine({ rate_limits: 'bad' })?.rate).toBeUndefined() + expect(parseStatusLine({ rate_limits: null })?.rate).toBeUndefined() + expect(parseStatusLine({ rate_limits: 42 })?.rate).toBeUndefined() + }) + + it('returns rate object with undefined sub-fields when rate_limits values are invalid', () => { + const result = parseStatusLine({ rate_limits: { fiveHourPct: NaN, sevenDayPct: Infinity } }) + + // rate_limits IS a valid object, so rate is returned; but both sub-fields are invalid + expect(result?.rate).toBeDefined() + expect(result?.rate?.fiveHourPct).toBeUndefined() + expect(result?.rate?.sevenDayPct).toBeUndefined() + }) + + it('omits invalid sub-fields and keeps valid ones', () => { + const result = parseStatusLine({ + rate_limits: { fiveHourPct: 'not-a-number', sevenDayPct: 80 }, + }) + + expect(result?.rate?.fiveHourPct).toBeUndefined() + expect(result?.rate?.sevenDayPct).toBe(80) + }) +}) + +describe('parseStatusLine — never throw (SEC-M7)', () => { + it('never throws for wildly malformed bodies', () => { + const malformed: unknown[] = [ + null, + undefined, + 42, + 'string', + true, + [], + { context_window: 'not-an-object' }, + { context_window: { used_percentage: {} } }, + { cost: null }, + { model: 42 }, + { effort: [] }, + { pr: 'not-an-object' }, + { pr: { number: 'bad', url: {} } }, + { rate_limits: 'bad' }, + { rate_limits: { fiveHourPct: Infinity, sevenDayPct: -Infinity } }, + { pr: { number: NaN, url: '' } }, + { context_window: { used_percentage: null } }, + { cost: { total_cost_usd: {}, total_lines_added: [], total_lines_removed: undefined } }, + ] + + for (const body of malformed) { + expect(() => parseStatusLine(body)).not.toThrow() + } + }) +}) diff --git a/test/http/worktrees-create.test.ts b/test/http/worktrees-create.test.ts new file mode 100644 index 0000000..9278c59 --- /dev/null +++ b/test/http/worktrees-create.test.ts @@ -0,0 +1,205 @@ +/** + * test/http/worktrees-create.test.ts — B3 git worktree creation (N-worktree). + * + * Covers the three new exports of src/http/worktrees.ts: + * - validateBranchName (pure: git-ref subset rejection rules, SEC-H2) + * - sanitizeBranchForDir (pure: branch → safe single dir segment) + * - computeWorktreeDir (realpath-based containment, SEC-H3/M2) + * - createWorktree (execFile, no shell, structured result, SEC-M10) + * + * Pure functions are unit-tested; createWorktree runs against real temporary + * git repos (no network) — endpoint wiring is exercised separately in + * T-server-wire's integration suite. + */ + +import { describe, it, expect, beforeAll } from 'vitest' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { + validateBranchName, + sanitizeBranchForDir, + computeWorktreeDir, + createWorktree, +} from '../../src/http/worktrees.js' + +const execFileP = promisify(execFile) +const TIMEOUT_MS = 10000 + +let gitAvailable = false +beforeAll(async () => { + try { + await execFileP('git', ['--version']) + gitAvailable = true + } catch { + gitAvailable = false + } +}) + +/** Create a temp git repo with one commit so `git worktree add -b` can run. */ +async function makeRepo(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-repo-')) + await execFileP('git', ['init', '-q', '-b', 'main'], { cwd: dir }) + await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd: dir }) + await execFileP('git', ['config', 'user.name', 'tester'], { cwd: dir }) + await execFileP('git', ['commit', '-q', '--allow-empty', '-m', 'init'], { cwd: dir }) + return dir +} + +// ── validateBranchName ───────────────────────────────────────────────────────── + +describe('validateBranchName', () => { + it('accepts ordinary branch names', () => { + for (const ok of ['feature', 'feature/login', 'fix-bug', 'v1.2.3', 'foo_bar', 'a/b/c']) { + expect(validateBranchName(ok)).toBe(true) + } + }) + + it('rejects empty and over-long names', () => { + expect(validateBranchName('')).toBe(false) + expect(validateBranchName('a'.repeat(251))).toBe(false) + }) + + it('rejects a leading dash (would be parsed as a git flag, SEC-H2)', () => { + expect(validateBranchName('-rf')).toBe(false) + expect(validateBranchName('-b')).toBe(false) + }) + + it('rejects path traversal and slash edge cases', () => { + for (const bad of ['../x', 'foo..bar', '/leading', 'trailing/', 'double//slash']) { + expect(validateBranchName(bad)).toBe(false) + } + }) + + it('rejects a trailing .lock', () => { + expect(validateBranchName('foo.lock')).toBe(false) + }) + + it('rejects whitespace and control characters', () => { + expect(validateBranchName('has space')).toBe(false) + expect(validateBranchName('tab\tx')).toBe(false) + expect(validateBranchName('ctrl\x01x')).toBe(false) + expect(validateBranchName('null\x00x')).toBe(false) + }) + + it('rejects git-forbidden punctuation ~^:?*[\\ and @{', () => { + for (const bad of ['a~b', 'a^b', 'a:b', 'a?b', 'a*b', 'a[b', 'back\\slash', 'foo@{1}']) { + expect(validateBranchName(bad)).toBe(false) + } + }) +}) + +// ── sanitizeBranchForDir ──────────────────────────────────────────────────────── + +describe('sanitizeBranchForDir', () => { + it('replaces slashes with dashes', () => { + expect(sanitizeBranchForDir('feature/login')).toBe('feature-login') + expect(sanitizeBranchForDir('a/b/c')).toBe('a-b-c') + }) + + it('trims leading/trailing dashes', () => { + expect(sanitizeBranchForDir('/foo/')).toBe('foo') + expect(sanitizeBranchForDir('-foo-')).toBe('foo') + }) + + it('replaces filesystem-unsafe characters with a dash', () => { + expect(sanitizeBranchForDir('a b')).toBe('a-b') + expect(sanitizeBranchForDir('weird$name')).toBe('weird-name') + }) + + it('preserves safe characters (alnum, dot, underscore, dash)', () => { + expect(sanitizeBranchForDir('v1.2.3_rc')).toBe('v1.2.3_rc') + }) +}) + +// ── computeWorktreeDir ────────────────────────────────────────────────────────── + +describe('computeWorktreeDir', () => { + it('places the worktree under an explicit root', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-root-')) + const dir = await computeWorktreeDir('/repo', 'feature', root) + expect(dir).toBe(path.join(root, 'feature')) + }) + + it('defaults the base to -worktrees alongside the repo', async () => { + const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-defbase-')) + const dir = await computeWorktreeDir(repo, 'foo') + expect(dir).toBe(path.join(path.dirname(repo), path.basename(repo) + '-worktrees', 'foo')) + }) + + it('throws when the sanitized segment resolves outside the root (.., M2)', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-esc-')) + await expect(computeWorktreeDir('/repo', '..', root)).rejects.toThrow() + }) + + it('rejects a symlinked candidate that escapes the root after realpath (AC-B3.4)', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-symroot-')) + const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-outside-')) + // Pre-plant a symlink at the predicted candidate path pointing outside root. + await fs.symlink(outside, path.join(root, 'feature')) + await expect(computeWorktreeDir('/repo', 'feature', root)).rejects.toThrow() + }) +}) + +// ── createWorktree (real temp repos) ──────────────────────────────────────────── + +describe('createWorktree', () => { + it('creates a new worktree + branch and returns its path', async () => { + if (!gitAvailable) return + const repo = await makeRepo() + const res = await createWorktree(repo, 'feature/new', { timeoutMs: TIMEOUT_MS }) + expect(res.ok).toBe(true) + expect(res.branch).toBe('feature/new') + expect(res.path).toBeDefined() + const stat = await fs.stat(res.path as string) + expect(stat.isDirectory()).toBe(true) + const { stdout } = await execFileP('git', ['worktree', 'list', '--porcelain'], { cwd: repo }) + expect(stdout).toContain(res.path as string) + expect(stdout).toContain('branch refs/heads/feature/new') + }) + + it('rejects an invalid branch name with 400 and never runs git', async () => { + if (!gitAvailable) return + const repo = await makeRepo() + const res = await createWorktree(repo, '../evil', { timeoutMs: TIMEOUT_MS }) + expect(res).toMatchObject({ ok: false, status: 400 }) + // No worktree was created (still just the main one). + const { stdout } = await execFileP('git', ['worktree', 'list'], { cwd: repo }) + expect(stdout.trim().split('\n')).toHaveLength(1) + }) + + it('returns 404 for a non-git directory', async () => { + const plain = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-plain-')) + const res = await createWorktree(plain, 'feature', { timeoutMs: TIMEOUT_MS }) + expect(res).toMatchObject({ ok: false, status: 404 }) + }) + + it('returns 404 for a non-absolute repo path', async () => { + const res = await createWorktree('relative/path', 'feature', { timeoutMs: TIMEOUT_MS }) + expect(res).toMatchObject({ ok: false, status: 404 }) + }) + + it('returns 409 when the branch already exists', async () => { + if (!gitAvailable) return + const repo = await makeRepo() + const first = await createWorktree(repo, 'dup', { timeoutMs: TIMEOUT_MS }) + expect(first.ok).toBe(true) + const second = await createWorktree(repo, 'dup', { timeoutMs: TIMEOUT_MS }) + expect(second).toMatchObject({ ok: false, status: 409 }) + expect(second.error).toBeDefined() + // Error message must not leak raw git stderr (SEC-M10). + expect(second.error).not.toContain('fatal:') + }) + + it('returns 400 when the computed dir escapes a symlinked root (M2)', async () => { + if (!gitAvailable) return + const repo = await makeRepo() + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-croot-')) + const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-cout-')) + await fs.symlink(outside, path.join(root, 'feature')) + const res = await createWorktree(repo, 'feature', { worktreeRoot: root, timeoutMs: TIMEOUT_MS }) + expect(res).toMatchObject({ ok: false, status: 400 }) + }) +}) diff --git a/test/integration/push.test.ts b/test/integration/push.test.ts new file mode 100644 index 0000000..8b85037 --- /dev/null +++ b/test/integration/push.test.ts @@ -0,0 +1,422 @@ +/** + * T-server-wire — integration tests for the A1 push / lock-screen-decision wiring. + * + * Covers (against a real startServer): + * - GET /push/vapid-key → 503 when VAPID unset, {publicKey} when set + * - POST /push/subscribe → Origin guard (403), 204 on success, 400 bad body, + * per-IP rate limit (429) + * - DELETE /push/subscribe → Origin guard (403), 204 on success + * - POST /hook/decision → Origin guard (403), bad/stale token (403), + * missing fields (400), rate limit (429), and the + * positive token path resolving a held approval + * - C1 hold gate (SEC-C2) → zero clients but ≥1 subscription → /hook/permission HELD + * - WS approve mode (SEC-M5) → ALLOW_AUTO_MODE gate downgrades mode:'auto' → 'default' + * + * web-push is mocked so the push fan-out "succeeds" and we can read the signed + * payload to recover the per-decision capability token for the positive path. + */ + +import net from 'node:net' +import os from 'node:os' +import path from 'node:path' +import fs from 'node:fs/promises' + +import { afterEach, describe, expect, it, vi } from 'vitest' +import WebSocket from 'ws' +import * as nodePty from 'node-pty' + +import { loadConfig } from '../../src/config.js' +import { startServer } from '../../src/server.js' + +// ── web-push mock (captures every signed payload string) ─────────────────────── +const { sentPayloads } = vi.hoisted(() => ({ sentPayloads: [] as string[] })) +vi.mock('web-push', () => ({ + default: { + setVapidDetails: (): void => {}, + sendNotification: async (_sub: unknown, payload: string): Promise => { + sentPayloads.push(payload) + }, + }, +})) + +const PTY_AVAILABLE = (() => { + try { + const p = nodePty.spawn(process.env['SHELL'] ?? '/bin/sh', [], { cols: 80, rows: 24 }) + p.kill() + return true + } catch { + return false + } +})() +const itPty = PTY_AVAILABLE ? it : it.skip + +// ── helpers ──────────────────────────────────────────────────────────────────── + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer() + srv.listen(0, '127.0.0.1', () => { + const addr = srv.address() + if (addr === null || typeof addr === 'string') { + srv.close() + reject(new Error('bad addr')) + return + } + const port = addr.port + srv.close(() => resolve(port)) + }) + srv.on('error', reject) + }) +} + +const handles: { close(): Promise }[] = [] +const tmpFiles: string[] = [] + +async function spawnServer( + overrides: Record = {}, +): Promise<{ port: number; origin: string }> { + const port = await getFreePort() + const storePath = path.join(os.tmpdir(), `webterm-push-${port}-${Date.now()}.json`) + tmpFiles.push(storePath) + const cfg = loadConfig({ + PORT: String(port), + BIND_HOST: '127.0.0.1', + SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh', + ALLOWED_ORIGINS: `http://127.0.0.1:${port}`, + USE_TMUX: '0', + IDLE_TTL: '86400', + PUSH_STORE_PATH: storePath, + ...overrides, + }) + const handle = startServer(cfg) + handles.push(handle) + await new Promise((r) => setTimeout(r, 80)) + return { port, origin: `http://127.0.0.1:${port}` } +} + +const VAPID_ON = { + VAPID_PUBLIC_KEY: 'test-public-key', + VAPID_PRIVATE_KEY: 'test-private-key', + VAPID_SUBJECT: 'mailto:test@localhost', +} + +function validSubscriptionBody(endpoint = 'https://push.example/ep-1'): string { + return JSON.stringify({ endpoint, keys: { p256dh: 'p256dh-value', auth: 'auth-value' } }) +} + +function waitForOpen(ws: WebSocket): Promise { + return new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error('open timeout')), 3000) + ws.once('open', () => { + clearTimeout(t) + resolve() + }) + ws.once('error', (e) => { + clearTimeout(t) + reject(e) + }) + }) +} + +function waitForMessage(ws: WebSocket, pred: (m: Record) => boolean): Promise> { + return new Promise((resolve, reject) => { + const t = setTimeout(() => { + ws.off('message', onMsg) + reject(new Error('message timeout')) + }, 5000) + const onMsg = (raw: WebSocket.RawData): void => { + let m: Record + try { + m = JSON.parse(raw.toString('utf8')) as Record + } catch { + return + } + if (pred(m)) { + clearTimeout(t) + ws.off('message', onMsg) + resolve(m) + } + } + ws.on('message', onMsg) + }) +} + +const isAttached = (m: Record): boolean => m['type'] === 'attached' + +afterEach(async () => { + while (handles.length > 0) await handles.pop()?.close() + for (const f of tmpFiles.splice(0)) await fs.rm(f, { force: true }).catch(() => undefined) + sentPayloads.length = 0 + await new Promise((r) => setTimeout(r, 30)) +}) + +// ── GET /push/vapid-key ───────────────────────────────────────────────────────── + +describe('GET /push/vapid-key', () => { + it('returns 503 when VAPID is not configured', async () => { + const { port } = await spawnServer() + const res = await fetch(`http://127.0.0.1:${port}/push/vapid-key`) + expect(res.status).toBe(503) + }) + + it('returns the public key when VAPID is configured', async () => { + const { port } = await spawnServer(VAPID_ON) + const res = await fetch(`http://127.0.0.1:${port}/push/vapid-key`) + expect(res.status).toBe(200) + const body = (await res.json()) as { publicKey?: string } + expect(body.publicKey).toBe('test-public-key') + }) +}) + +// ── POST/DELETE /push/subscribe ────────────────────────────────────────────────── + +describe('POST /push/subscribe', () => { + it('rejects a foreign Origin with 403 (SEC-C4)', async () => { + const { port } = await spawnServer(VAPID_ON) + const res = await fetch(`http://127.0.0.1:${port}/push/subscribe`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: 'http://evil.example' }, + body: validSubscriptionBody(), + }) + expect(res.status).toBe(403) + }) + + it('rejects a missing Origin with 403 (default-deny)', async () => { + const { port } = await spawnServer(VAPID_ON) + const res = await fetch(`http://127.0.0.1:${port}/push/subscribe`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: validSubscriptionBody(), + }) + expect(res.status).toBe(403) + }) + + it('stores a valid subscription with 204 for an allowed Origin', async () => { + const { port, origin } = await spawnServer(VAPID_ON) + const res = await fetch(`http://127.0.0.1:${port}/push/subscribe`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: origin }, + body: validSubscriptionBody(), + }) + expect(res.status).toBe(204) + }) + + it('rejects an invalid subscription body with 400', async () => { + const { port, origin } = await spawnServer(VAPID_ON) + const res = await fetch(`http://127.0.0.1:${port}/push/subscribe`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: origin }, + body: JSON.stringify({ endpoint: '' }), + }) + expect(res.status).toBe(400) + }) + + it('rate-limits more than 5 subscribe calls/min from one IP (429, SEC-H9)', async () => { + const { port, origin } = await spawnServer(VAPID_ON) + const codes: number[] = [] + for (let i = 0; i < 6; i++) { + const res = await fetch(`http://127.0.0.1:${port}/push/subscribe`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: origin }, + body: validSubscriptionBody(`https://push.example/ep-${i}`), + }) + codes.push(res.status) + } + expect(codes.slice(0, 5).every((c) => c === 204)).toBe(true) + expect(codes[5]).toBe(429) + }) +}) + +describe('DELETE /push/subscribe', () => { + it('rejects a foreign Origin with 403', async () => { + const { port } = await spawnServer(VAPID_ON) + const res = await fetch(`http://127.0.0.1:${port}/push/subscribe`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json', Origin: 'http://evil.example' }, + body: JSON.stringify({ endpoint: 'https://push.example/ep-1' }), + }) + expect(res.status).toBe(403) + }) + + it('removes a subscription with 204 for an allowed Origin', async () => { + const { port, origin } = await spawnServer(VAPID_ON) + await fetch(`http://127.0.0.1:${port}/push/subscribe`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: origin }, + body: validSubscriptionBody(), + }) + const res = await fetch(`http://127.0.0.1:${port}/push/subscribe`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json', Origin: origin }, + body: JSON.stringify({ endpoint: 'https://push.example/ep-1' }), + }) + expect(res.status).toBe(204) + }) +}) + +// ── POST /hook/decision (security paths) ───────────────────────────────────────── + +describe('POST /hook/decision — guards (SEC-C1/M1/H9)', () => { + const sid = '00000000-0000-4000-8000-000000000000' + + it('rejects a foreign Origin with 403', async () => { + const { port } = await spawnServer(VAPID_ON) + const res = await fetch(`http://127.0.0.1:${port}/hook/decision`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: 'http://evil.example' }, + body: JSON.stringify({ sessionId: sid, decision: 'allow', token: 'x' }), + }) + expect(res.status).toBe(403) + }) + + it('rejects a missing Origin with 403', async () => { + const { port } = await spawnServer(VAPID_ON) + const res = await fetch(`http://127.0.0.1:${port}/hook/decision`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId: sid, decision: 'allow', token: 'x' }), + }) + expect(res.status).toBe(403) + }) + + it('rejects an unknown/stale token with 403', async () => { + const { port, origin } = await spawnServer(VAPID_ON) + const res = await fetch(`http://127.0.0.1:${port}/hook/decision`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: origin }, + body: JSON.stringify({ sessionId: sid, decision: 'allow', token: 'not-a-real-token' }), + }) + expect(res.status).toBe(403) + }) + + it('rejects a malformed body with 400', async () => { + const { port, origin } = await spawnServer(VAPID_ON) + const res = await fetch(`http://127.0.0.1:${port}/hook/decision`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: origin }, + body: JSON.stringify({ sessionId: sid }), + }) + expect(res.status).toBe(400) + }) + + it('rate-limits more than 10 decision calls/min from one IP (429)', async () => { + const { port, origin } = await spawnServer(VAPID_ON) + const codes: number[] = [] + for (let i = 0; i < 11; i++) { + const res = await fetch(`http://127.0.0.1:${port}/hook/decision`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: origin }, + body: JSON.stringify({ sessionId: sid, decision: 'allow', token: 'x' }), + }) + codes.push(res.status) + } + // First 10 hit the handler (403 stale token); the 11th is rate-limited. + expect(codes.slice(0, 10).every((c) => c === 403)).toBe(true) + expect(codes[10]).toBe(429) + }) +}) + +// ── C1 hold gate + decision positive path (needs real PTY) ────────────────────── + +describe('C1 hold gate + /hook/decision positive path', () => { + itPty( + 'holds a permission with zero clients but ≥1 subscription, then resolves via /hook/decision', + async () => { + const { port, origin } = await spawnServer({ ...VAPID_ON, PERM_TIMEOUT_MS: '4000' }) + + // 1. Register a push subscription (so push is "enabled and has a target"). + await fetch(`http://127.0.0.1:${port}/push/subscribe`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: origin }, + body: validSubscriptionBody(), + }) + + // 2. Create a session via WS, then detach (zero clients, PTY alive). + const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { headers: { Origin: origin } }) + await waitForOpen(ws) + ws.send(JSON.stringify({ type: 'attach', sessionId: null })) + const attached = await waitForMessage(ws, isAttached) + const sessionId = attached['sessionId'] as string + ws.close() + await new Promise((r) => setTimeout(r, 150)) + + // 3. Fire the held permission hook (loopback). Do NOT await it. + sentPayloads.length = 0 + let resolved = false + const permPromise = fetch(`http://127.0.0.1:${port}/hook/permission`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId }, + body: JSON.stringify({ tool_name: 'Bash' }), + }).then(async (r) => { + resolved = true + return (await r.json()) as { hookSpecificOutput?: { decision?: { behavior?: string } } } + }) + + // 4. The push must have fired (carrying the capability token) and the hook + // must still be HELD (C1: it did not immediately fall through to {}). + await new Promise((r) => setTimeout(r, 250)) + expect(sentPayloads.length).toBe(1) + expect(resolved).toBe(false) + const token = (JSON.parse(sentPayloads[0]!) as { token?: string }).token + expect(typeof token).toBe('string') + + // 5. Resolve it from a "remote" device via /hook/decision with the token. + const decRes = await fetch(`http://127.0.0.1:${port}/hook/decision`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: origin }, + body: JSON.stringify({ sessionId, decision: 'allow', token }), + }) + expect(decRes.status).toBe(204) + + const decision = await permPromise + expect(decision.hookSpecificOutput?.decision?.behavior).toBe('allow') + }, + ) +}) + +// ── WS approve mode + ALLOW_AUTO_MODE gate (needs real PTY) ────────────────────── + +describe('WS approve with permission mode (SEC-M5)', () => { + async function approveWithMode( + autoAllowed: boolean, + mode: string, + ): Promise<{ behavior?: string; mode?: string }> { + const { port, origin } = await spawnServer({ ALLOW_AUTO_MODE: autoAllowed ? '1' : '0' }) + const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { headers: { Origin: origin } }) + await waitForOpen(ws) + ws.send(JSON.stringify({ type: 'attach', sessionId: null })) + const attached = await waitForMessage(ws, isAttached) + const sessionId = attached['sessionId'] as string + + const permPromise = fetch(`http://127.0.0.1:${port}/hook/permission`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId }, + body: JSON.stringify({ tool_name: 'ExitPlanMode' }), + }) + await waitForMessage(ws, (m) => m['pending'] === true) + ws.send(JSON.stringify({ type: 'approve', mode })) + const decision = (await (await permPromise).json()) as { + hookSpecificOutput?: { decision?: { behavior?: string; mode?: string } } + } + ws.close() + return decision.hookSpecificOutput?.decision ?? {} + } + + itPty('keeps mode:auto when ALLOW_AUTO_MODE=1', async () => { + const d = await approveWithMode(true, 'auto') + expect(d.behavior).toBe('allow') + expect(d.mode).toBe('auto') + }) + + itPty('downgrades mode:auto → default when ALLOW_AUTO_MODE=0', async () => { + const d = await approveWithMode(false, 'auto') + expect(d.behavior).toBe('allow') + expect(d.mode).toBe('default') + }) + + itPty('passes through mode:acceptEdits unchanged', async () => { + const d = await approveWithMode(false, 'acceptEdits') + expect(d.behavior).toBe('allow') + expect(d.mode).toBe('acceptEdits') + }) +}) diff --git a/test/integration/timeline-events.test.ts b/test/integration/timeline-events.test.ts new file mode 100644 index 0000000..b96963e --- /dev/null +++ b/test/integration/timeline-events.test.ts @@ -0,0 +1,217 @@ +/** + * T-server-wire — integration tests for the A4 timeline, B2 statusLine ingest, + * and the GET /config/ui route. + * + * Covers (against a real startServer): + * - GET /live-sessions/:id/events → 404 unknown id; [] when TIMELINE_ENABLED=0; + * real timeline entries after a /hook POST + * - POST /hook/status (loopback) → 400 on garbage; 204 + telemetry broadcast + * - GET /config/ui → mirrors cfg.allowAutoMode + */ + +import net from 'node:net' + +import { afterEach, describe, expect, it } from 'vitest' +import WebSocket from 'ws' +import * as nodePty from 'node-pty' + +import { loadConfig } from '../../src/config.js' +import { startServer } from '../../src/server.js' + +const PTY_AVAILABLE = (() => { + try { + const p = nodePty.spawn(process.env['SHELL'] ?? '/bin/sh', [], { cols: 80, rows: 24 }) + p.kill() + return true + } catch { + return false + } +})() +const itPty = PTY_AVAILABLE ? it : it.skip + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer() + srv.listen(0, '127.0.0.1', () => { + const addr = srv.address() + if (addr === null || typeof addr === 'string') { + srv.close() + reject(new Error('bad addr')) + return + } + const port = addr.port + srv.close(() => resolve(port)) + }) + srv.on('error', reject) + }) +} + +const handles: { close(): Promise }[] = [] + +async function spawnServer( + overrides: Record = {}, +): Promise<{ port: number; origin: string }> { + const port = await getFreePort() + const cfg = loadConfig({ + PORT: String(port), + BIND_HOST: '127.0.0.1', + SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh', + ALLOWED_ORIGINS: `http://127.0.0.1:${port}`, + USE_TMUX: '0', + IDLE_TTL: '86400', + ...overrides, + }) + const handle = startServer(cfg) + handles.push(handle) + await new Promise((r) => setTimeout(r, 80)) + return { port, origin: `http://127.0.0.1:${port}` } +} + +function waitForOpen(ws: WebSocket): Promise { + return new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error('open timeout')), 3000) + ws.once('open', () => { + clearTimeout(t) + resolve() + }) + ws.once('error', (e) => { + clearTimeout(t) + reject(e) + }) + }) +} + +function waitForMessage(ws: WebSocket, pred: (m: Record) => boolean): Promise> { + return new Promise((resolve, reject) => { + const t = setTimeout(() => { + ws.off('message', onMsg) + reject(new Error('message timeout')) + }, 5000) + const onMsg = (raw: WebSocket.RawData): void => { + let m: Record + try { + m = JSON.parse(raw.toString('utf8')) as Record + } catch { + return + } + if (pred(m)) { + clearTimeout(t) + ws.off('message', onMsg) + resolve(m) + } + } + ws.on('message', onMsg) + }) +} + +const isAttached = (m: Record): boolean => m['type'] === 'attached' + +afterEach(async () => { + while (handles.length > 0) await handles.pop()?.close() + await new Promise((r) => setTimeout(r, 30)) +}) + +const UNKNOWN = '00000000-0000-4000-8000-000000000000' + +// ── GET /live-sessions/:id/events ──────────────────────────────────────────────── + +describe('GET /live-sessions/:id/events (A4)', () => { + it('returns 404 for an unknown session id when the timeline is enabled', async () => { + const { port } = await spawnServer() + const res = await fetch(`http://127.0.0.1:${port}/live-sessions/${UNKNOWN}/events`) + expect(res.status).toBe(404) + }) + + it('returns an empty array when TIMELINE_ENABLED=0 (AC-A4.6)', async () => { + const { port } = await spawnServer({ TIMELINE_ENABLED: '0' }) + const res = await fetch(`http://127.0.0.1:${port}/live-sessions/${UNKNOWN}/events`) + expect(res.status).toBe(200) + expect(await res.json()).toEqual([]) + }) + + itPty('returns derived timeline entries after a /hook POST', async () => { + const { port, origin } = await spawnServer() + const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { headers: { Origin: origin } }) + await waitForOpen(ws) + ws.send(JSON.stringify({ type: 'attach', sessionId: null })) + const attached = await waitForMessage(ws, isAttached) + const sessionId = attached['sessionId'] as string + + const hookRes = await fetch(`http://127.0.0.1:${port}/hook`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId }, + body: JSON.stringify({ hook_event_name: 'PreToolUse', tool_name: 'Bash' }), + }) + expect(hookRes.status).toBe(204) + + const evRes = await fetch(`http://127.0.0.1:${port}/live-sessions/${sessionId}/events`) + expect(evRes.status).toBe(200) + const events = (await evRes.json()) as { class: string; label: string; toolName?: string }[] + expect(Array.isArray(events)).toBe(true) + const tool = events.find((e) => e.class === 'tool') + expect(tool).toBeDefined() + expect(tool!.label).toContain('Bash') + + ws.close() + }) +}) + +// ── POST /hook/status (B2) ─────────────────────────────────────────────────────── + +describe('POST /hook/status (B2)', () => { + it('rejects a non-object body with 400', async () => { + const { port } = await spawnServer() + const res = await fetch(`http://127.0.0.1:${port}/hook/status`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': UNKNOWN }, + body: JSON.stringify('not-an-object'), + }) + expect(res.status).toBe(400) + }) + + itPty('ingests telemetry from loopback and broadcasts it to the attached client', async () => { + const { port, origin } = await spawnServer() + const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { headers: { Origin: origin } }) + await waitForOpen(ws) + ws.send(JSON.stringify({ type: 'attach', sessionId: null })) + const attached = await waitForMessage(ws, isAttached) + const sessionId = attached['sessionId'] as string + + const telemetryPromise = waitForMessage(ws, (m) => m['type'] === 'telemetry') + + const res = await fetch(`http://127.0.0.1:${port}/hook/status`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId }, + body: JSON.stringify({ + context_window: { used_percentage: 42 }, + cost: { total_cost_usd: 1.23 }, + model: { display_name: 'opus' }, + }), + }) + expect(res.status).toBe(204) + + const msg = (await telemetryPromise) as { telemetry: Record } + expect(msg.telemetry['contextUsedPct']).toBe(42) + expect(msg.telemetry['costUsd']).toBe(1.23) + expect(msg.telemetry['model']).toBe('opus') + + ws.close() + }) +}) + +// ── GET /config/ui (review #4) ──────────────────────────────────────────────────── + +describe('GET /config/ui', () => { + it('reports allowAutoMode:false by default', async () => { + const { port } = await spawnServer() + const res = await fetch(`http://127.0.0.1:${port}/config/ui`) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ allowAutoMode: false }) + }) + + it('reports allowAutoMode:true when ALLOW_AUTO_MODE=1', async () => { + const { port } = await spawnServer({ ALLOW_AUTO_MODE: '1' }) + const res = await fetch(`http://127.0.0.1:${port}/config/ui`) + expect(await res.json()).toEqual({ allowAutoMode: true }) + }) +}) diff --git a/test/integration/worktree.test.ts b/test/integration/worktree.test.ts new file mode 100644 index 0000000..8d28acf --- /dev/null +++ b/test/integration/worktree.test.ts @@ -0,0 +1,203 @@ +/** + * T-server-wire — integration tests for the B3 worktree-create and B1 diff routes. + * + * Covers (against a real startServer): + * - POST /projects/worktree → Origin guard (403), WORKTREE_ENABLED=0 (403), + * invalid branch (400), missing fields (400), + * real creation in a temp git repo (git-gated) + * - GET /projects/diff → 400 missing path, 404 non-git path, structured + * DiffResult for a real repo with verbatim content + */ + +import net from 'node:net' +import os from 'node:os' +import path from 'node:path' +import fs from 'node:fs/promises' +import { execFileSync } from 'node:child_process' + +import { afterEach, describe, expect, it } from 'vitest' + +import { loadConfig } from '../../src/config.js' +import { startServer } from '../../src/server.js' +import type { DiffResult } from '../../src/types.js' + +const GIT_AVAILABLE = (() => { + try { + execFileSync('git', ['--version'], { stdio: 'ignore' }) + return true + } catch { + return false + } +})() +const itGit = GIT_AVAILABLE ? it : it.skip + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer() + srv.listen(0, '127.0.0.1', () => { + const addr = srv.address() + if (addr === null || typeof addr === 'string') { + srv.close() + reject(new Error('bad addr')) + return + } + const port = addr.port + srv.close(() => resolve(port)) + }) + srv.on('error', reject) + }) +} + +const handles: { close(): Promise }[] = [] +const tmpDirs: string[] = [] + +async function spawnServer( + overrides: Record = {}, +): Promise<{ port: number; origin: string }> { + const port = await getFreePort() + const cfg = loadConfig({ + PORT: String(port), + BIND_HOST: '127.0.0.1', + SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh', + ALLOWED_ORIGINS: `http://127.0.0.1:${port}`, + USE_TMUX: '0', + IDLE_TTL: '86400', + ...overrides, + }) + const handle = startServer(cfg) + handles.push(handle) + await new Promise((r) => setTimeout(r, 80)) + return { port, origin: `http://127.0.0.1:${port}` } +} + +/** Create a temp git repo with one committed file. Returns its absolute path. */ +async function makeRealRepo(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-wt-')) + tmpDirs.push(dir) + const git = (args: string[]): void => { + execFileSync('git', args, { cwd: dir, stdio: 'ignore' }) + } + git(['init']) + git(['config', 'user.email', 'test@localhost']) + git(['config', 'user.name', 'Test']) + git(['config', 'commit.gpgsign', 'false']) + await fs.writeFile(path.join(dir, 'file.txt'), 'line1\nline2\n', 'utf8') + git(['add', '.']) + git(['commit', '-m', 'init']) + return dir +} + +afterEach(async () => { + while (handles.length > 0) await handles.pop()?.close() + for (const d of tmpDirs.splice(0)) await fs.rm(d, { recursive: true, force: true }).catch(() => undefined) + await new Promise((r) => setTimeout(r, 30)) +}) + +// ── POST /projects/worktree ────────────────────────────────────────────────────── + +describe('POST /projects/worktree (B3)', () => { + it('rejects a foreign Origin with 403 (SEC-C3)', async () => { + const { port } = await spawnServer() + const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: 'http://evil.example' }, + body: JSON.stringify({ path: '/tmp/x', branch: 'feature' }), + }) + expect(res.status).toBe(403) + }) + + it('rejects a missing Origin with 403', async () => { + const { port } = await spawnServer() + const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: '/tmp/x', branch: 'feature' }), + }) + expect(res.status).toBe(403) + }) + + it('returns 403 when WORKTREE_ENABLED=0', async () => { + const { port, origin } = await spawnServer({ WORKTREE_ENABLED: '0' }) + const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: origin }, + body: JSON.stringify({ path: '/tmp/x', branch: 'feature' }), + }) + expect(res.status).toBe(403) + }) + + it('rejects an invalid branch name with 400 (no git executed, SEC-H2)', async () => { + const { port, origin } = await spawnServer() + const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: origin }, + body: JSON.stringify({ path: '/tmp/x', branch: '../evil' }), + }) + expect(res.status).toBe(400) + }) + + it('rejects a missing branch field with 400', async () => { + const { port, origin } = await spawnServer() + const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: origin }, + body: JSON.stringify({ path: '/tmp/x' }), + }) + expect(res.status).toBe(400) + }) + + itGit('creates a worktree in a real git repo and returns its path', async () => { + const repo = await makeRealRepo() + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-wtroot-')) + tmpDirs.push(root) + const { port, origin } = await spawnServer({ WORKTREE_ROOT: root }) + + const res = await fetch(`http://127.0.0.1:${port}/projects/worktree`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: origin }, + body: JSON.stringify({ path: repo, branch: 'feature-x' }), + }) + expect(res.status).toBe(200) + const body = (await res.json()) as { ok: boolean; path?: string; branch?: string } + expect(body.ok).toBe(true) + expect(body.branch).toBe('feature-x') + expect(typeof body.path).toBe('string') + // The created dir actually exists and is a worktree. + const list = execFileSync('git', ['worktree', 'list'], { cwd: repo }).toString() + expect(list).toContain(body.path!) + }) +}) + +// ── GET /projects/diff ─────────────────────────────────────────────────────────── + +describe('GET /projects/diff (B1)', () => { + it('returns 400 when the path query parameter is missing', async () => { + const { port } = await spawnServer() + const res = await fetch(`http://127.0.0.1:${port}/projects/diff`) + expect(res.status).toBe(400) + }) + + it('returns 404 for a non-git directory (SEC-H7)', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-nogit-')) + tmpDirs.push(dir) + const { port } = await spawnServer() + const res = await fetch(`http://127.0.0.1:${port}/projects/diff?path=${encodeURIComponent(dir)}`) + expect(res.status).toBe(404) + }) + + itGit('returns a structured DiffResult with verbatim content (AC-B1.4)', async () => { + const repo = await makeRealRepo() + // Introduce a tracked change containing a would-be XSS payload. + await fs.writeFile(path.join(repo, 'file.txt'), 'line1\n\n', 'utf8') + const { port } = await spawnServer() + + const res = await fetch(`http://127.0.0.1:${port}/projects/diff?path=${encodeURIComponent(repo)}`) + expect(res.status).toBe(200) + const diff = (await res.json()) as DiffResult + expect(diff.staged).toBe(false) + expect(diff.files.length).toBeGreaterThan(0) + const flat = JSON.stringify(diff) + // The diff carries the raw text verbatim (the FE renders it inert via textContent). + expect(flat).toContain('') + }) +}) diff --git a/test/manager.test.ts b/test/manager.test.ts index 5be9161..d5846d6 100644 --- a/test/manager.test.ts +++ b/test/manager.test.ts @@ -13,7 +13,14 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import type { Config, Dims, Session, WebSocketLike } from '../src/types.js'; +import type { + Config, + Dims, + NotifyService, + Session, + StatusTelemetry, + WebSocketLike, +} from '../src/types.js'; import { WS_OPEN } from '../src/types.js'; import { createMockPty, type MockIPty } from './helpers/mock-pty.js'; @@ -53,10 +60,53 @@ const CFG: Config = { previewBytes: 24 * 1024, useTmux: false, allowedOrigins: [], + // v0.6 project manager + projectRoots: ['/home/tester'], + projectScanDepth: 4, + projectScanTtlMs: 10_000, + projectDirtyCheck: true, + editorCmd: 'code', + // v0.7 Walk-away Workbench + vapidPublicKey: undefined, + vapidPrivateKey: undefined, + vapidSubject: undefined, + pushStorePath: '/home/tester/.web-terminal-push-subs.json', + pushMaxSubs: 50, + notifyDone: true, + notifyDnd: false, + decisionTokenTtlMs: 300_000, + timelineMax: 200, + timelineEnabled: true, + stuckTtlMs: 10_000, // 10 s for easy arithmetic in tests + stuckAlert: true, + diffTimeoutMs: 2000, + diffMaxBytes: 2 * 1024 * 1024, + diffMaxFiles: 300, + statuslineTtlMs: 30_000, + worktreeEnabled: true, + worktreeRoot: undefined, + worktreeTimeoutMs: 10_000, + defaultPermissionMode: 'default', + allowAutoMode: false, }; const DIMS: Dims = { cols: 80, rows: 24 }; +/** A telemetry sample for B2 tests. */ +function makeTelemetry(at = 1_000): StatusTelemetry { + return { contextUsedPct: 42, costUsd: 1.23, model: 'sonnet', at }; +} + +/** A mock NotifyService (DI) recording every notify(session, cls, token?) call. */ +function createMockNotify() { + const notify = vi.fn(async (_session: Session, _cls: string, _token?: string) => {}); + const service: NotifyService = { + isEnabled: () => true, + notify, + }; + return { service, notify }; +} + interface MockWs extends WebSocketLike { readyState: number; sent: string[]; @@ -646,3 +696,356 @@ describe('onExit removes session from table when ws attached at exit time (L2)', expect(session.exitedAt).not.toBeNull(); }); }); + +// ── handleHookEvent — A4 timeline + B4 gate (T-manager) ─────────────────────── +describe('handleHookEvent — timeline append (A4)', () => { + it('appends a derived timeline event when eventClass is supplied + timeline enabled', () => { + const mgr = createSessionManager(CFG); + const ws = createMockWs(); + const s = mgr.handleAttach(ws, null, DIMS, 1_000); + + mgr.handleHookEvent(s.meta.id, 'working', 'Bash', false, undefined, 'PreToolUse', 'Bash'); + + expect(s.timeline).toHaveLength(1); + expect(s.timeline[0]).toMatchObject({ class: 'tool', toolName: 'Bash', label: 'using Bash' }); + expect(typeof s.timeline[0]?.at).toBe('number'); + }); + + it('replaces the timeline array immutably (never mutates the previous one)', () => { + const mgr = createSessionManager(CFG); + const ws = createMockWs(); + const s = mgr.handleAttach(ws, null, DIMS, 1_000); + const before = s.timeline; + + mgr.handleHookEvent(s.meta.id, 'working', undefined, false, undefined, 'PreToolUse', 'Bash'); + + expect(s.timeline).not.toBe(before); + expect(before).toHaveLength(0); + }); + + it('does NOT append when timeline is disabled', () => { + const mgr = createSessionManager({ ...CFG, timelineEnabled: false }); + const ws = createMockWs(); + const s = mgr.handleAttach(ws, null, DIMS, 1_000); + + mgr.handleHookEvent(s.meta.id, 'working', undefined, false, undefined, 'PreToolUse', 'Bash'); + + expect(s.timeline).toHaveLength(0); + }); + + it('does NOT append when eventClass is omitted', () => { + const mgr = createSessionManager(CFG); + const ws = createMockWs(); + const s = mgr.handleAttach(ws, null, DIMS, 1_000); + + mgr.handleHookEvent(s.meta.id, 'waiting', 'Bash', true); + + expect(s.timeline).toHaveLength(0); + }); + + it('drops non-whitelisted event classes (makeTimelineEvent → null)', () => { + const mgr = createSessionManager(CFG); + const ws = createMockWs(); + const s = mgr.handleAttach(ws, null, DIMS, 1_000); + + mgr.handleHookEvent(s.meta.id, 'working', undefined, false, undefined, 'BogusEvent', 'X'); + + expect(s.timeline).toHaveLength(0); + }); + + it('evicts oldest events past cfg.timelineMax', () => { + const mgr = createSessionManager({ ...CFG, timelineMax: 3 }); + const ws = createMockWs(); + const s = mgr.handleAttach(ws, null, DIMS, 1_000); + + for (let i = 0; i < 5; i += 1) { + mgr.handleHookEvent(s.meta.id, 'working', undefined, false, undefined, 'PreToolUse', `Tool${i}`); + } + + expect(s.timeline).toHaveLength(3); + expect(s.timeline.map((e) => e.toolName)).toEqual(['Tool2', 'Tool3', 'Tool4']); + }); +}); + +describe('handleHookEvent — gate broadcast (B4)', () => { + it('broadcasts the status frame with gate when supplied', () => { + const mgr = createSessionManager(CFG); + const ws = createMockWs(); + const s = mgr.handleAttach(ws, null, DIMS, 1_000); + ws.sent.length = 0; + + mgr.handleHookEvent(s.meta.id, 'waiting', 'ExitPlanMode', true, 'plan', 'PermissionRequest', 'ExitPlanMode'); + + const status = parseSent(ws).find((m) => m.type === 'status'); + expect(status).toMatchObject({ + type: 'status', + status: 'waiting', + detail: 'ExitPlanMode', + pending: true, + gate: 'plan', + }); + }); + + it('omits gate when not supplied', () => { + const mgr = createSessionManager(CFG); + const ws = createMockWs(); + const s = mgr.handleAttach(ws, null, DIMS, 1_000); + ws.sent.length = 0; + + mgr.handleHookEvent(s.meta.id, 'waiting', 'Bash', true); + + const status = parseSent(ws).find((m) => m.type === 'status'); + expect(status).not.toHaveProperty('gate'); + }); +}); + +// ── handleStatusLine (B2 telemetry) ─────────────────────────────────────────── +describe('handleStatusLine', () => { + it('stores telemetry on the session and broadcasts it to all clients', () => { + const mgr = createSessionManager(CFG); + const a = createMockWs(); + const b = createMockWs(); + const s = mgr.handleAttach(a, null, DIMS, 1_000); + mgr.handleAttach(b, s.meta.id, DIMS, 2_000); + a.sent.length = 0; + b.sent.length = 0; + + const telemetry = makeTelemetry(); + mgr.handleStatusLine(s.meta.id, telemetry); + + expect(s.telemetry).toBe(telemetry); + for (const ws of [a, b]) { + const msg = parseSent(ws).find((m) => m.type === 'telemetry'); + expect(msg).toMatchObject({ type: 'telemetry', telemetry: { costUsd: 1.23, model: 'sonnet' } }); + } + }); + + it('is a no-op for an unknown session id', () => { + const mgr = createSessionManager(CFG); + expect(() => mgr.handleStatusLine('00000000-0000-4000-8000-0000000000cc', makeTelemetry())).not.toThrow(); + }); +}); + +// ── handleAttach Case 2 — late-join telemetry/status replay (M3 / AC-B2.3) ───── +describe('handleAttach — late-join replay (M3)', () => { + it('sends the current telemetry to a device joining a live session', () => { + const mgr = createSessionManager(CFG); + const a = createMockWs(); + const s = mgr.handleAttach(a, null, DIMS, 1_000); + mgr.handleStatusLine(s.meta.id, makeTelemetry()); + + const b = createMockWs(); + mgr.handleAttach(b, s.meta.id, DIMS, 2_000); + + const msg = parseSent(b).find((m) => m.type === 'telemetry'); + expect(msg).toMatchObject({ type: 'telemetry', telemetry: { model: 'sonnet' } }); + }); + + it('sends the current status to a late-joining device', () => { + const mgr = createSessionManager(CFG); + const a = createMockWs(); + const s = mgr.handleAttach(a, null, DIMS, 1_000); + mgr.handleHookEvent(s.meta.id, 'working'); + + const b = createMockWs(); + mgr.handleAttach(b, s.meta.id, DIMS, 2_000); + + const status = parseSent(b).find((m) => m.type === 'status'); + expect(status).toMatchObject({ type: 'status', status: 'working' }); + }); + + it('does NOT send a telemetry frame when the session has no telemetry yet', () => { + const mgr = createSessionManager(CFG); + const a = createMockWs(); + const s = mgr.handleAttach(a, null, DIMS, 1_000); + + const b = createMockWs(); + mgr.handleAttach(b, s.meta.id, DIMS, 2_000); + + expect(parseSent(b).some((m) => m.type === 'telemetry')).toBe(false); + }); +}); + +// ── sweepStuck (A5) ─────────────────────────────────────────────────────────── +describe('sweepStuck (A5)', () => { + it('marks a silent live session stuck, broadcasts, and notifies once', () => { + const { service, notify } = createMockNotify(); + const mgr = createSessionManager(CFG, service); + const ws = createMockWs(); + const s = mgr.handleAttach(ws, null, DIMS, 1_000); // lastOutputAt = 1000 + ws.sent.length = 0; + + mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1); + + expect(s.claudeStatus).toBe('stuck'); + expect(s.stuckNotified).toBe(true); + const status = parseSent(ws).find((m) => m.type === 'status'); + expect(status).toMatchObject({ type: 'status', status: 'stuck' }); + expect(notify).toHaveBeenCalledTimes(1); + expect(notify).toHaveBeenCalledWith(s, 'stuck'); + }); + + it('covers ATTACHED sessions, not only detached ones (AC-A5.3)', () => { + const { service, notify } = createMockNotify(); + const mgr = createSessionManager(CFG, service); + const ws = createMockWs(); + const s = mgr.handleAttach(ws, null, DIMS, 1_000); + // still attached: clients.size === 1, detachedAt === null + + mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1); + + expect(s.detachedAt).toBeNull(); + expect(s.claudeStatus).toBe('stuck'); + expect(notify).toHaveBeenCalledTimes(1); + }); + + it('alerts at most once per silent round (AC-A5.1)', () => { + const { service, notify } = createMockNotify(); + const mgr = createSessionManager(CFG, service); + const ws = createMockWs(); + const s = mgr.handleAttach(ws, null, DIMS, 1_000); + + mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1); + mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 9_999); + + expect(notify).toHaveBeenCalledTimes(1); + expect(s.claudeStatus).toBe('stuck'); + }); + + it('re-arms after output recovery so a later silence alerts again (AC-A5.2)', () => { + const { service, notify } = createMockNotify(); + const mgr = createSessionManager(CFG, service); + const ws = createMockWs(); + const s = mgr.handleAttach(ws, null, DIMS, 1_000); + + mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1); + expect(notify).toHaveBeenCalledTimes(1); + + // Output recovery (session.ts onData) re-arms the flag + status. + (s.pty as MockIPty).emitData('back to work'); + expect(s.stuckNotified).toBe(false); + expect(s.claudeStatus).toBe('working'); + s.lastOutputAt = 100_000; // pin to a known cursor + + mgr.sweepStuck(100_000 + CFG.stuckTtlMs + 1); + expect(notify).toHaveBeenCalledTimes(2); + expect(s.claudeStatus).toBe('stuck'); + }); + + it('skips idle sessions', () => { + const { service, notify } = createMockNotify(); + const mgr = createSessionManager(CFG, service); + const ws = createMockWs(); + const s = mgr.handleAttach(ws, null, DIMS, 1_000); + s.claudeStatus = 'idle'; + + mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1); + + expect(s.claudeStatus).toBe('idle'); + expect(notify).not.toHaveBeenCalled(); + }); + + it('skips already-exited sessions (kept in table after detach-then-exit)', () => { + const { service, notify } = createMockNotify(); + const mgr = createSessionManager(CFG, service); + const ws = createMockWs(); + const s = mgr.handleAttach(ws, null, DIMS, 1_000); + s.clients.clear(); + s.detachedAt = 2_000; + (s.pty as MockIPty).emitExit(0); // detached-then-exit → stays in table (L1) + + mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1); + + expect(s.claudeStatus).not.toBe('stuck'); + expect(notify).not.toHaveBeenCalled(); + }); + + it('does not mark a session whose output is within the TTL window', () => { + const { service, notify } = createMockNotify(); + const mgr = createSessionManager(CFG, service); + const ws = createMockWs(); + const s = mgr.handleAttach(ws, null, DIMS, 1_000); + + mgr.sweepStuck(1_000 + CFG.stuckTtlMs - 1); + + expect(s.claudeStatus).not.toBe('stuck'); + expect(notify).not.toHaveBeenCalled(); + }); + + it('is disabled when stuckAlert is false', () => { + const { service, notify } = createMockNotify(); + const mgr = createSessionManager({ ...CFG, stuckAlert: false }, service); + const ws = createMockWs(); + const s = mgr.handleAttach(ws, null, DIMS, 1_000); + + mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1); + + expect(s.claudeStatus).not.toBe('stuck'); + expect(notify).not.toHaveBeenCalled(); + }); + + it('is disabled when stuckTtlMs is 0', () => { + const { service, notify } = createMockNotify(); + const mgr = createSessionManager({ ...CFG, stuckTtlMs: 0 }, service); + const ws = createMockWs(); + const s = mgr.handleAttach(ws, null, DIMS, 1_000); + + mgr.sweepStuck(99_999_999); + + expect(s.claudeStatus).not.toBe('stuck'); + expect(notify).not.toHaveBeenCalled(); + }); + + it('swallows a notifyService rejection (logs via console.error, never throws)', async () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const notify = vi.fn(async () => { + throw new Error('push transport down'); + }); + const service: NotifyService = { isEnabled: () => true, notify }; + const mgr = createSessionManager(CFG, service); + const ws = createMockWs(); + const s = mgr.handleAttach(ws, null, DIMS, 1_000); + + expect(() => mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1)).not.toThrow(); + await Promise.resolve(); + await Promise.resolve(); + + expect(s.claudeStatus).toBe('stuck'); + expect(errSpy).toHaveBeenCalled(); + errSpy.mockRestore(); + }); + + it('works without an injected notifyService (broadcasts, no throw)', () => { + const mgr = createSessionManager(CFG); // no notifyService + const ws = createMockWs(); + const s = mgr.handleAttach(ws, null, DIMS, 1_000); + ws.sent.length = 0; + + expect(() => mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1)).not.toThrow(); + expect(s.claudeStatus).toBe('stuck'); + expect(parseSent(ws).some((m) => m.type === 'status' && m.status === 'stuck')).toBe(true); + }); +}); + +// ── list() carries telemetry (B2 thumbnail wall) ────────────────────────────── +describe('list — telemetry field', () => { + it('includes the latest telemetry for each session', () => { + const mgr = createSessionManager(CFG); + const ws = createMockWs(); + const s = mgr.handleAttach(ws, null, DIMS, 1_000); + const telemetry = makeTelemetry(); + mgr.handleStatusLine(s.meta.id, telemetry); + + const entry = mgr.list().find((e) => e.id === s.meta.id); + expect(entry?.telemetry).toBe(telemetry); + }); + + it('reports null telemetry before the first statusLine report', () => { + const mgr = createSessionManager(CFG); + const ws = createMockWs(); + const s = mgr.handleAttach(ws, null, DIMS, 1_000); + + const entry = mgr.list().find((e) => e.id === s.meta.id); + expect(entry?.telemetry).toBeNull(); + }); +}); diff --git a/test/push.test.ts b/test/push.test.ts new file mode 100644 index 0000000..db262b2 --- /dev/null +++ b/test/push.test.ts @@ -0,0 +1,508 @@ +// @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 + toJSON: ReturnType + getKey: ReturnType +} + +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 | 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() + }) +}) diff --git a/test/push/push-service.test.ts b/test/push/push-service.test.ts new file mode 100644 index 0000000..21c6964 --- /dev/null +++ b/test/push/push-service.test.ts @@ -0,0 +1,326 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { + Config, + NotifyClass, + PushPayload, + PushSubscriptionRecord, + Session, +} from '../../src/types.js' +import type { SubscriptionStore } from '../../src/push/subscription-store.js' +import { + createPushService, + type PushSendOptions, + type PushSender, +} from '../../src/push/push-service.js' + +/* ── fixtures ─────────────────────────────────────────────────────── */ + +const BASE_CFG: Config = { + port: 3000, + bindHost: '0.0.0.0', + shellPath: '/bin/zsh', + homeDir: '/home/tester', + idleTtlMs: 10_000, + scrollbackBytes: 2 * 1024 * 1024, + maxPayloadBytes: 1024 * 1024, + wsPath: '/term', + maxSessions: 50, + maxMsgsPerSec: 2000, + permTimeoutMs: 300_000, + reapIntervalMs: 60_000, + previewBytes: 24 * 1024, + useTmux: false, + allowedOrigins: [], + projectRoots: ['/home/tester'], + projectScanDepth: 4, + projectScanTtlMs: 10_000, + projectDirtyCheck: true, + editorCmd: 'code', + vapidPublicKey: 'PUBKEY', + vapidPrivateKey: 'PRIVKEY', + vapidSubject: 'mailto:admin@example.com', + pushStorePath: '/tmp/push-subs.json', + pushMaxSubs: 50, + notifyDone: true, + notifyDnd: false, + decisionTokenTtlMs: 300_000, + timelineMax: 200, + timelineEnabled: true, + stuckTtlMs: 600_000, + stuckAlert: true, + diffTimeoutMs: 2000, + diffMaxBytes: 2 * 1024 * 1024, + diffMaxFiles: 300, + statuslineTtlMs: 30_000, + worktreeEnabled: true, + worktreeRoot: undefined, + worktreeTimeoutMs: 10_000, + defaultPermissionMode: 'default', + allowAutoMode: false, +} + +function cfg(overrides: Partial = {}): Config { + return { ...BASE_CFG, ...overrides } +} + +const SID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479' + +function fakeSession(id = SID, cwd: string | null = '/home/tester/my-repo'): Session { + return { meta: { id, createdAt: 0, shellPath: '/bin/zsh' }, cwd } as unknown as Session +} + +function rec(endpoint: string): PushSubscriptionRecord { + return { endpoint, keys: { p256dh: 'p-' + endpoint, auth: 'a-' + endpoint }, createdAt: 1 } +} + +interface SentCall { + record: PushSubscriptionRecord + payload: PushPayload + options: PushSendOptions +} + +/** A fake sender that records calls and can be told to throw per-endpoint. */ +function makeSender(failures: Record = {}): PushSender & { + sent: SentCall[] + vapid: { subject: string; publicKey: string; privateKey: string } | null +} { + const sent: SentCall[] = [] + let vapid: { subject: string; publicKey: string; privateKey: string } | null = null + return { + sent, + get vapid() { + return vapid + }, + setVapid(subject, publicKey, privateKey) { + vapid = { subject, publicKey, privateKey } + }, + async send(record, payload, options) { + const code = failures[record.endpoint] + if (code) { + const e = new Error('push failed') as Error & { statusCode: number } + e.statusCode = code + throw e + } + sent.push({ record, payload: JSON.parse(payload) as PushPayload, options }) + }, + } +} + +/** A fake store backed by an in-memory array. */ +function makeStore(initial: PushSubscriptionRecord[] = []): SubscriptionStore & { + pruned: string[][] + persistCount: number +} { + let records = [...initial] + const pruned: string[][] = [] + let persistCount = 0 + return { + pruned, + get persistCount() { + return persistCount + }, + list: () => Object.freeze([...records]), + add(r) { + records = [...records.filter((x) => x.endpoint !== r.endpoint), r] + }, + remove(endpoint) { + records = records.filter((x) => x.endpoint !== endpoint) + }, + prune(dead) { + pruned.push([...dead]) + records = records.filter((x) => !dead.includes(x.endpoint)) + }, + async persist() { + persistCount++ + }, + } +} + +beforeEach(() => { + vi.restoreAllMocks() +}) + +/* ── isEnabled truth table ────────────────────────────────────────── */ + +describe('createPushService — isEnabled', () => { + it('is true only when both VAPID keys are configured', () => { + expect(createPushService(cfg(), makeStore(), { sender: makeSender() }).isEnabled()).toBe(true) + expect( + createPushService(cfg({ vapidPublicKey: undefined }), makeStore(), { sender: makeSender() }).isEnabled(), + ).toBe(false) + expect( + createPushService(cfg({ vapidPrivateKey: undefined }), makeStore(), { sender: makeSender() }).isEnabled(), + ).toBe(false) + expect( + createPushService(cfg({ vapidPublicKey: undefined, vapidPrivateKey: undefined }), makeStore(), { + sender: makeSender(), + }).isEnabled(), + ).toBe(false) + }) + + it('registers VAPID details with the sender when enabled', () => { + const sender = makeSender() + createPushService(cfg(), makeStore(), { sender }) + expect(sender.vapid).toEqual({ + subject: 'mailto:admin@example.com', + publicKey: 'PUBKEY', + privateKey: 'PRIVKEY', + }) + }) + + it('does not register VAPID details when disabled', () => { + const sender = makeSender() + createPushService(cfg({ vapidPrivateKey: undefined }), makeStore(), { sender }) + expect(sender.vapid).toBeNull() + }) + + it('falls back to a default subject when VAPID_SUBJECT is unset', () => { + const sender = makeSender() + createPushService(cfg({ vapidSubject: undefined }), makeStore(), { sender }) + expect(sender.vapid?.subject).toMatch(/^mailto:/) + }) +}) + +/* ── notify: short-circuits ───────────────────────────────────────── */ + +describe('notify — short-circuits', () => { + it('is a no-op when push is disabled', async () => { + const sender = makeSender() + const svc = createPushService(cfg({ vapidPublicKey: undefined }), makeStore([rec('e1')]), { sender }) + await svc.notify(fakeSession(), 'needs-input', 'tok') + expect(sender.sent).toHaveLength(0) + }) + + it('is a no-op when NOTIFY_DND is on', async () => { + const sender = makeSender() + const svc = createPushService(cfg({ notifyDnd: true }), makeStore([rec('e1')]), { sender }) + await svc.notify(fakeSession(), 'needs-input', 'tok') + expect(sender.sent).toHaveLength(0) + }) + + it('drops DONE notifications when NOTIFY_DONE is off, but still sends needs-input/stuck', async () => { + const sender = makeSender() + const svc = createPushService(cfg({ notifyDone: false }), makeStore([rec('e1')]), { sender }) + await svc.notify(fakeSession(), 'done') + expect(sender.sent).toHaveLength(0) + await svc.notify(fakeSession(), 'needs-input', 'tok') + await svc.notify(fakeSession(), 'stuck') + expect(sender.sent.map((c) => c.payload.cls)).toEqual(['needs-input', 'stuck']) + }) + + it('is a no-op when there are no subscriptions', async () => { + const sender = makeSender() + const svc = createPushService(cfg(), makeStore([]), { sender }) + await svc.notify(fakeSession(), 'needs-input', 'tok') + expect(sender.sent).toHaveLength(0) + }) +}) + +/* ── notify: payload shape ────────────────────────────────────────── */ + +describe('notify — payload (§3.3 PushPayload)', () => { + it('sends a minimal needs-input payload with the capability token to every subscription', async () => { + const sender = makeSender() + const svc = createPushService(cfg(), makeStore([rec('e1'), rec('e2')]), { sender }) + await svc.notify(fakeSession(), 'needs-input', 'cap-tok') + + expect(sender.sent).toHaveLength(2) + const { payload } = sender.sent[0]! + expect(payload.sessionId).toBe(SID) + expect(payload.cls).toBe('needs-input') + expect(payload.token).toBe('cap-tok') + // minimal: only known keys, no raw output / secrets + expect(Object.keys(payload).sort()).toEqual(['cls', 'detail', 'sessionId', 'token']) + expect(payload.detail).toBe('my-repo') + }) + + it('omits the token for non-needs-input classes even when one is passed', async () => { + const sender = makeSender() + const svc = createPushService(cfg(), makeStore([rec('e1')]), { sender }) + await svc.notify(fakeSession(), 'done', 'leaked-token') + expect(sender.sent[0]!.payload.token).toBeUndefined() + }) + + it('omits detail when the session has no cwd', async () => { + const sender = makeSender() + const svc = createPushService(cfg(), makeStore([rec('e1')]), { sender }) + await svc.notify(fakeSession(SID, null), 'stuck') + expect(sender.sent[0]!.payload.detail).toBeUndefined() + }) +}) + +/* ── notify: web-push options ─────────────────────────────────────── */ + +describe('notify — send options', () => { + it('uses high urgency for needs-input/stuck and low for done', async () => { + const sender = makeSender() + const svc = createPushService(cfg(), makeStore([rec('e1')]), { sender }) + await svc.notify(fakeSession(), 'needs-input', 'tok') + await svc.notify(fakeSession(), 'stuck') + await svc.notify(fakeSession(), 'done') + expect(sender.sent.map((c) => c.options.urgency)).toEqual(['high', 'high', 'low']) + }) + + it('sets a per-session collapse topic (valid Topic header, no dashes, <=32 chars)', async () => { + const sender = makeSender() + const svc = createPushService(cfg(), makeStore([rec('e1')]), { sender }) + await svc.notify(fakeSession(), 'needs-input', 'tok') + const topic = sender.sent[0]!.options.topic! + expect(topic).toBe(SID.replace(/-/g, '')) + expect(topic.length).toBeLessThanOrEqual(32) + expect(topic).toMatch(/^[A-Za-z0-9_-]+$/) + }) +}) + +/* ── notify: pruning dead subscriptions ───────────────────────────── */ + +describe('notify — pruning', () => { + for (const code of [404, 410] as const) { + it(`prunes + persists a subscription that returns ${code}`, async () => { + const sender = makeSender({ e2: code }) + const store = makeStore([rec('e1'), rec('e2'), rec('e3')]) + const svc = createPushService(cfg(), store, { sender }) + await svc.notify(fakeSession(), 'needs-input', 'tok') + + expect(store.pruned).toEqual([['e2']]) + expect(store.persistCount).toBe(1) + // the live subscriptions still received the push + expect(sender.sent.map((c) => c.record.endpoint).sort()).toEqual(['e1', 'e3']) + }) + } + + it('does NOT prune on transient errors (e.g. 500); logs and keeps the subscription', async () => { + const err = vi.spyOn(console, 'error').mockImplementation(() => {}) + const sender = makeSender({ e1: 500 }) + const store = makeStore([rec('e1'), rec('e2')]) + const svc = createPushService(cfg(), store, { sender }) + await svc.notify(fakeSession(), 'done') + + expect(store.pruned).toEqual([]) + expect(store.persistCount).toBe(0) + expect(err).toHaveBeenCalled() + expect(sender.sent.map((c) => c.record.endpoint)).toEqual(['e2']) + }) + + it('does not persist when no subscriptions are dead', async () => { + const sender = makeSender() + const store = makeStore([rec('e1')]) + const svc = createPushService(cfg(), store, { sender }) + await svc.notify(fakeSession(), 'needs-input', 'tok') + expect(store.persistCount).toBe(0) + }) +}) + +/* ── default sender (web-push) is wired without crashing ──────────── */ + +describe('createPushService — default sender', () => { + it('constructs with the real web-push sender when none is injected', () => { + // disabled config → setVapidDetails not called, so no real keys needed + const svc = createPushService(cfg({ vapidPublicKey: undefined, vapidPrivateKey: undefined }), makeStore()) + expect(svc.isEnabled()).toBe(false) + }) +}) + +/* exhaustiveness guard: every NotifyClass is exercised above */ +const _classes: NotifyClass[] = ['needs-input', 'done', 'stuck'] +void _classes diff --git a/test/push/subscription-store.test.ts b/test/push/subscription-store.test.ts new file mode 100644 index 0000000..278ebf5 --- /dev/null +++ b/test/push/subscription-store.test.ts @@ -0,0 +1,170 @@ +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() + }) +}) diff --git a/test/quick-reply.test.ts b/test/quick-reply.test.ts new file mode 100644 index 0000000..4bc132c --- /dev/null +++ b/test/quick-reply.test.ts @@ -0,0 +1,552 @@ +// @vitest-environment jsdom +/** + * test/quick-reply.test.ts — N-quickreply: quick-reply chips + saved-prompt palette. + * + * Feature: A3 (Walk-away Workbench) + * Owns: public/quick-reply.ts + * + * Acceptance criteria (AC-A3.x): + * AC-A3.1 Built-in chips present and send correct bytes (including \r). + * AC-A3.2 User palette CRUD is immutable; round-trip persists via localStorage. + * AC-A3.3 Chip click sends via the onSend path (same text + optional \r). + * AC-A3.4 Labels with special characters rendered as inert text (textContent), + * never injected as HTML (SEC-L3). + * + * Coverage target: ≥80% of public/quick-reply.ts + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// ── import module under test ────────────────────────────────────────────────── +const mod = await import('../public/quick-reply.js') +const { + BUILT_IN_CHIPS, + addChip, + removeChip, + reorderChip, + updateChip, + loadPalette, + savePalette, + mountQuickReply, + PALETTE_KEY, +} = mod + +// ── helpers ─────────────────────────────────────────────────────────────────── + +function makeChip(over: Partial = {}): import('../public/quick-reply.js').Chip { + return { + id: 'test-id', + text: 'hello', + label: 'hello', + appendEnter: true, + ...over, + } +} + +beforeEach(() => { + localStorage.clear() + vi.restoreAllMocks() +}) + +// ─────────────────────────── BUILT_IN_CHIPS ────────────────────────────────── + +describe('BUILT_IN_CHIPS', () => { + it('exports exactly 6 built-in chips', () => { + expect(BUILT_IN_CHIPS).toHaveLength(6) + }) + + it('contains yes, continue, 1, 2, 3, Esc (in that order)', () => { + const labels = BUILT_IN_CHIPS.map((c) => c.label) + expect(labels).toContain('yes') + expect(labels).toContain('continue') + expect(labels).toContain('1') + expect(labels).toContain('2') + expect(labels).toContain('3') + expect(labels).toContain('Esc') + }) + + it('yes chip has appendEnter=true and text "yes"', () => { + const yes = BUILT_IN_CHIPS.find((c) => c.label === 'yes') + expect(yes).toBeDefined() + expect(yes!.appendEnter).toBe(true) + expect(yes!.text).toBe('yes') + }) + + it('continue chip has appendEnter=true and text "continue"', () => { + const cont = BUILT_IN_CHIPS.find((c) => c.label === 'continue') + expect(cont).toBeDefined() + expect(cont!.appendEnter).toBe(true) + expect(cont!.text).toBe('continue') + }) + + it('1/2/3 chips have appendEnter=true', () => { + for (const label of ['1', '2', '3']) { + const chip = BUILT_IN_CHIPS.find((c) => c.label === label) + expect(chip).toBeDefined() + expect(chip!.appendEnter).toBe(true) + } + }) + + it('Esc chip has appendEnter=false and text \\x1b', () => { + const esc = BUILT_IN_CHIPS.find((c) => c.label === 'Esc') + expect(esc).toBeDefined() + expect(esc!.appendEnter).toBe(false) + expect(esc!.text).toBe('\x1b') + expect(esc!.text.charCodeAt(0)).toBe(0x1b) + }) + + it('all built-in chips have unique ids', () => { + const ids = BUILT_IN_CHIPS.map((c) => c.id) + expect(new Set(ids).size).toBe(6) + }) + + it('is frozen / immutable (modification attempt does not affect original)', () => { + // Reading from a frozen array should work; appending returns new array + const extended = [...BUILT_IN_CHIPS, makeChip({ id: 'extra' })] + expect(extended).toHaveLength(7) + expect(BUILT_IN_CHIPS).toHaveLength(6) + }) +}) + +// ─────────────────────────── addChip ───────────────────────────────────────── + +describe('addChip', () => { + it('returns a new array with the chip appended', () => { + const chips = [makeChip({ id: 'a' })] + const newChip = makeChip({ id: 'b' }) + const result = addChip(chips, newChip) + expect(result).toHaveLength(2) + expect(result[1]).toBe(newChip) + }) + + it('does NOT mutate the original array', () => { + const chips = [makeChip({ id: 'a' })] + const original = [...chips] + addChip(chips, makeChip({ id: 'b' })) + expect(chips).toHaveLength(original.length) + expect(chips[0]).toBe(original[0]) + }) + + it('works on an empty array', () => { + const chip = makeChip({ id: 'first' }) + const result = addChip([], chip) + expect(result).toHaveLength(1) + expect(result[0]).toBe(chip) + }) + + it('returns a different array reference', () => { + const chips = [makeChip()] + const result = addChip(chips, makeChip({ id: 'x' })) + expect(result).not.toBe(chips) + }) +}) + +// ─────────────────────────── removeChip ────────────────────────────────────── + +describe('removeChip', () => { + it('removes the chip with the matching id', () => { + const a = makeChip({ id: 'a' }) + const b = makeChip({ id: 'b' }) + const c = makeChip({ id: 'c' }) + const result = removeChip([a, b, c], 'b') + expect(result).toHaveLength(2) + expect(result.find((ch) => ch.id === 'b')).toBeUndefined() + }) + + it('does NOT mutate the original array', () => { + const chips = [makeChip({ id: 'a' }), makeChip({ id: 'b' })] + removeChip(chips, 'a') + expect(chips).toHaveLength(2) + }) + + it('returns a different array reference', () => { + const chips = [makeChip({ id: 'a' })] + expect(removeChip(chips, 'a')).not.toBe(chips) + }) + + it('no-op when id not found — length unchanged', () => { + const chips = [makeChip({ id: 'a' })] + const result = removeChip(chips, 'nonexistent') + expect(result).toHaveLength(1) + }) + + it('removes only the matched id when duplicates do not exist', () => { + const a = makeChip({ id: 'a', label: 'A' }) + const b = makeChip({ id: 'b', label: 'B' }) + const result = removeChip([a, b], 'a') + expect(result).toHaveLength(1) + expect(result[0]!.label).toBe('B') + }) +}) + +// ─────────────────────────── reorderChip ───────────────────────────────────── + +describe('reorderChip', () => { + it('moves a chip from fromIndex to toIndex', () => { + const a = makeChip({ id: 'a' }) + const b = makeChip({ id: 'b' }) + const c = makeChip({ id: 'c' }) + const result = reorderChip([a, b, c], 0, 2) + expect(result.map((ch) => ch.id)).toEqual(['b', 'c', 'a']) + }) + + it('does NOT mutate the original array', () => { + const chips = [makeChip({ id: 'a' }), makeChip({ id: 'b' })] + reorderChip(chips, 0, 1) + expect(chips[0]!.id).toBe('a') + }) + + it('returns a different array reference', () => { + const chips = [makeChip({ id: 'a' }), makeChip({ id: 'b' })] + expect(reorderChip(chips, 0, 1)).not.toBe(chips) + }) + + it('moves to the same position (no-op reorder)', () => { + const a = makeChip({ id: 'a' }) + const b = makeChip({ id: 'b' }) + const result = reorderChip([a, b], 0, 0) + expect(result.map((ch) => ch.id)).toEqual(['a', 'b']) + }) + + it('handles out-of-bounds fromIndex gracefully', () => { + const chips = [makeChip({ id: 'a' })] + const result = reorderChip(chips, 99, 0) + expect(result).toHaveLength(1) + }) + + it('handles out-of-bounds toIndex gracefully', () => { + const chips = [makeChip({ id: 'a' })] + const result = reorderChip(chips, 0, 99) + expect(result).toHaveLength(1) + }) +}) + +// ─────────────────────────── updateChip ────────────────────────────────────── + +describe('updateChip', () => { + it('updates the chip with matching id (partial patch)', () => { + const chip = makeChip({ id: 'x', label: 'before' }) + const result = updateChip([chip], 'x', { label: 'after' }) + expect(result[0]!.label).toBe('after') + expect(result[0]!.id).toBe('x') // id preserved + }) + + it('does NOT mutate the original chip object', () => { + const chip = makeChip({ id: 'x', label: 'original' }) + updateChip([chip], 'x', { label: 'changed' }) + expect(chip.label).toBe('original') + }) + + it('does NOT mutate the original array', () => { + const chips = [makeChip({ id: 'x' })] + updateChip(chips, 'x', { label: 'new' }) + expect(chips).toHaveLength(1) + }) + + it('returns a different array reference', () => { + const chips = [makeChip({ id: 'x' })] + expect(updateChip(chips, 'x', {})).not.toBe(chips) + }) + + it('leaves non-matching chips unchanged', () => { + const a = makeChip({ id: 'a', label: 'A' }) + const b = makeChip({ id: 'b', label: 'B' }) + const result = updateChip([a, b], 'a', { label: 'A2' }) + expect(result[1]!.label).toBe('B') + expect(result[1]).toBe(b) + }) + + it('no-op when id not found', () => { + const chip = makeChip({ id: 'x', label: 'X' }) + const result = updateChip([chip], 'nonexistent', { label: 'changed' }) + expect(result[0]!.label).toBe('X') + }) + + it('can update appendEnter', () => { + const chip = makeChip({ id: 'x', appendEnter: true }) + const result = updateChip([chip], 'x', { appendEnter: false }) + expect(result[0]!.appendEnter).toBe(false) + }) +}) + +// ─────────────────────────── loadPalette ───────────────────────────────────── + +describe('loadPalette', () => { + it('returns empty array when localStorage has no key', () => { + expect(loadPalette()).toEqual([]) + }) + + it('returns the stored chips on valid JSON', () => { + const chips = [makeChip({ id: 'p1', text: 'run tests', label: 'run tests', appendEnter: true })] + localStorage.setItem(PALETTE_KEY, JSON.stringify(chips)) + const result = loadPalette() + expect(result).toHaveLength(1) + expect(result[0]!.id).toBe('p1') + expect(result[0]!.text).toBe('run tests') + }) + + it('does NOT throw on bad JSON', () => { + localStorage.setItem(PALETTE_KEY, 'not-valid-json{{{') + expect(() => loadPalette()).not.toThrow() + expect(loadPalette()).toEqual([]) + }) + + it('does NOT throw when localStorage contains a non-array JSON value', () => { + localStorage.setItem(PALETTE_KEY, JSON.stringify({ foo: 'bar' })) + expect(() => loadPalette()).not.toThrow() + expect(loadPalette()).toEqual([]) + }) + + it('returns empty array when stored data has items with missing fields', () => { + const bad = [{ id: 'x' }] // missing text/label/appendEnter + localStorage.setItem(PALETTE_KEY, JSON.stringify(bad)) + expect(() => loadPalette()).not.toThrow() + // Either returns [] or filters invalid items + const result = loadPalette() + expect(Array.isArray(result)).toBe(true) + const valid = result.filter((c) => typeof c.id === 'string' && typeof c.text === 'string') + expect(valid.length).toBe(0) + }) + + it('returns empty array when stored data is null JSON', () => { + localStorage.setItem(PALETTE_KEY, 'null') + expect(loadPalette()).toEqual([]) + }) + + it('does NOT throw when localStorage throws (simulated private mode)', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('Storage blocked in private mode') + }) + expect(() => loadPalette()).not.toThrow() + expect(loadPalette()).toEqual([]) + }) +}) + +// ─────────────────────────── savePalette ───────────────────────────────────── + +describe('savePalette', () => { + it('saves chips to localStorage', () => { + const chips = [makeChip({ id: 'saved' })] + savePalette(chips) + const raw = localStorage.getItem(PALETTE_KEY) + expect(raw).toBeTruthy() + const parsed = JSON.parse(raw!) + expect(parsed).toHaveLength(1) + expect(parsed[0].id).toBe('saved') + }) + + it('saves an empty array without throwing', () => { + expect(() => savePalette([])).not.toThrow() + const raw = localStorage.getItem(PALETTE_KEY) + expect(JSON.parse(raw!)).toEqual([]) + }) + + it('does NOT throw when localStorage is unavailable', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('Storage quota exceeded') + }) + expect(() => savePalette([makeChip()])).not.toThrow() + }) +}) + +// ─────────────────────────── round-trip persistence ────────────────────────── + +describe('savePalette + loadPalette — round-trip', () => { + it('persists and restores a palette with multiple chips', () => { + const chips = [ + makeChip({ id: 'a', text: 'run tests', label: 'run tests', appendEnter: true }), + makeChip({ id: 'b', text: '/clear', label: '/clear', appendEnter: false }), + ] + savePalette(chips) + const loaded = loadPalette() + expect(loaded).toHaveLength(2) + expect(loaded[0]!.text).toBe('run tests') + expect(loaded[1]!.text).toBe('/clear') + expect(loaded[1]!.appendEnter).toBe(false) + }) + + it('persists appendEnter=false correctly', () => { + savePalette([makeChip({ id: 'x', appendEnter: false })]) + const loaded = loadPalette() + expect(loaded[0]!.appendEnter).toBe(false) + }) +}) + +// ─────────────────────────── mountQuickReply ───────────────────────────────── + +describe('mountQuickReply — DOM', () => { + it('renders built-in chip buttons inside the container', () => { + const container = document.createElement('div') + const onSend = vi.fn() + const handle = mountQuickReply(container, { onSend }) + const buttons = container.querySelectorAll('button.qr-chip') + // All 6 built-in chips should be rendered + expect(buttons.length).toBeGreaterThanOrEqual(6) + handle.dispose() + }) + + it('clicking a built-in chip with appendEnter=true sends text + \\r', () => { + const container = document.createElement('div') + const onSend = vi.fn() + const handle = mountQuickReply(container, { onSend }) + + // Find the 'yes' chip button + const yesBtn = Array.from(container.querySelectorAll('button.qr-chip')).find( + (b) => b.textContent === 'yes', + ) + expect(yesBtn).toBeDefined() + ;(yesBtn as HTMLButtonElement).click() + expect(onSend).toHaveBeenCalledWith('yes\r') + handle.dispose() + }) + + it('clicking the Esc chip (appendEnter=false) sends \\x1b without \\r', () => { + const container = document.createElement('div') + const onSend = vi.fn() + const handle = mountQuickReply(container, { onSend }) + + const escBtn = Array.from(container.querySelectorAll('button.qr-chip')).find( + (b) => b.textContent === 'Esc', + ) + expect(escBtn).toBeDefined() + ;(escBtn as HTMLButtonElement).click() + expect(onSend).toHaveBeenCalledWith('\x1b') + expect(onSend).not.toHaveBeenCalledWith('\x1b\r') + handle.dispose() + }) + + it('user palette chips are also rendered', () => { + localStorage.setItem( + PALETTE_KEY, + JSON.stringify([makeChip({ id: 'u1', label: 'run tests', text: 'run tests', appendEnter: true })]), + ) + const container = document.createElement('div') + const onSend = vi.fn() + const handle = mountQuickReply(container, { onSend }) + + const buttons = Array.from(container.querySelectorAll('button.qr-chip')) + const found = buttons.find((b) => b.textContent === 'run tests') + expect(found).toBeDefined() + handle.dispose() + }) + + it('user palette chip click sends correct text', () => { + localStorage.setItem( + PALETTE_KEY, + JSON.stringify([makeChip({ id: 'u1', label: 'commit', text: 'git commit -m', appendEnter: false })]), + ) + const container = document.createElement('div') + const onSend = vi.fn() + const handle = mountQuickReply(container, { onSend }) + + const commitBtn = Array.from(container.querySelectorAll('button.qr-chip')).find( + (b) => b.textContent === 'commit', + ) + ;(commitBtn as HTMLButtonElement).click() + expect(onSend).toHaveBeenCalledWith('git commit -m') + handle.dispose() + }) + + it('chip labels with ', text: 'safe', appendEnter: false }), + ]), + ) + const container = document.createElement('div') + const onSend = vi.fn() + const handle = mountQuickReply(container, { onSend }) + + // No ', + ) + expect(xssBtn).toBeDefined() + handle.dispose() + }) + + it('contains a + button to add a new snippet', () => { + const container = document.createElement('div') + const onSend = vi.fn() + const handle = mountQuickReply(container, { onSend }) + const addBtn = container.querySelector('.qr-add-btn') + expect(addBtn).not.toBeNull() + handle.dispose() + }) + + it('dispose removes chip buttons from the container', () => { + const container = document.createElement('div') + const handle = mountQuickReply(container, { onSend: vi.fn() }) + expect(container.children.length).toBeGreaterThan(0) + handle.dispose() + expect(container.children.length).toBe(0) + }) + + it('inline editor appears when + is clicked', () => { + const container = document.createElement('div') + const handle = mountQuickReply(container, { onSend: vi.fn() }) + const addBtn = container.querySelector('.qr-add-btn') as HTMLButtonElement + addBtn.click() + expect(container.querySelector('.qr-editor')).not.toBeNull() + handle.dispose() + }) + + it('inline editor cancel button removes the editor', () => { + const container = document.createElement('div') + const handle = mountQuickReply(container, { onSend: vi.fn() }) + const addBtn = container.querySelector('.qr-add-btn') as HTMLButtonElement + addBtn.click() + const cancelBtn = container.querySelector('.qr-editor-cancel') as HTMLButtonElement + cancelBtn.click() + expect(container.querySelector('.qr-editor')).toBeNull() + handle.dispose() + }) + + it('saving a new chip from editor persists it to localStorage and re-renders', () => { + const container = document.createElement('div') + const handle = mountQuickReply(container, { onSend: vi.fn() }) + const addBtn = container.querySelector('.qr-add-btn') as HTMLButtonElement + addBtn.click() + + const textInput = container.querySelector('.qr-editor-text') as HTMLInputElement + textInput.value = 'write tests' + const labelInput = container.querySelector('.qr-editor-label') as HTMLInputElement + labelInput.value = 'write tests' + + const saveBtn = container.querySelector('.qr-editor-save') as HTMLButtonElement + saveBtn.click() + + // Editor should be gone + expect(container.querySelector('.qr-editor')).toBeNull() + // New chip should be in localStorage + const stored = loadPalette() + expect(stored.some((c) => c.text === 'write tests')).toBe(true) + // New chip button should appear + const newBtn = Array.from(container.querySelectorAll('button.qr-chip')).find( + (b) => b.textContent === 'write tests', + ) + expect(newBtn).toBeDefined() + handle.dispose() + }) + + it('editor save with empty text is a no-op', () => { + const container = document.createElement('div') + const handle = mountQuickReply(container, { onSend: vi.fn() }) + const countBefore = loadPalette().length + + const addBtn = container.querySelector('.qr-add-btn') as HTMLButtonElement + addBtn.click() + // Leave text empty + const saveBtn = container.querySelector('.qr-editor-save') as HTMLButtonElement + saveBtn.click() + + // Editor stays open (no text to save) + const countAfter = loadPalette().length + expect(countAfter).toBe(countBefore) + handle.dispose() + }) +}) diff --git a/test/session.test.ts b/test/session.test.ts index f376c3a..94ba205 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -53,6 +53,12 @@ const CFG: Config = { previewBytes: 24 * 1024, useTmux: false, allowedOrigins: [], + // v0.6 project manager fields + projectRoots: ['/home/tester'], + projectScanDepth: 4, + projectScanTtlMs: 10_000, + projectDirtyCheck: true, + editorCmd: 'code', }; const DIMS: Dims = { cols: 80, rows: 24 }; @@ -407,3 +413,129 @@ describe('kill', () => { expect((s.pty as MockIPty).killed).toBe(true); }); }); + +// ── spawn env (T-spawn-env / B2) ────────────────────────────────────────────── +describe('spawn env', () => { + it('injects WEBTERM_STATUSLINE_URL pointing to /hook/status (B2)', () => { + newSession(); + const opts = spawnCalls[0]!.options as { env?: Record }; + expect(opts.env?.['WEBTERM_STATUSLINE_URL']).toBe( + `http://127.0.0.1:${CFG.port}/hook/status`, + ); + }); + + it('still injects WEBTERM_SESSION and WEBTERM_HOOK_URL (existing env vars)', () => { + const s = newSession(); + const opts = spawnCalls[0]!.options as { env?: Record }; + expect(opts.env?.['WEBTERM_SESSION']).toBe(s.meta.id); + expect(opts.env?.['WEBTERM_HOOK_URL']).toBe(`http://127.0.0.1:${CFG.port}/hook`); + }); + + it('uses port from cfg (not hardcoded) in all URL env vars', () => { + nextPty = createMockPty(); + const altCfg: Config = { ...CFG, port: 4321 }; + createSession(altCfg, DIMS, 1_000, () => {}); + const opts = spawnCalls[0]!.options as { env?: Record }; + expect(opts.env?.['WEBTERM_STATUSLINE_URL']).toContain('4321'); + expect(opts.env?.['WEBTERM_HOOK_URL']).toContain('4321'); + }); +}); + +// ── session initialization — v0.7 fields (T-spawn-env) ─────────────────────── +describe('session v0.7 field initialization', () => { + it('initializes timeline as an empty frozen array', () => { + const s = newSession(); + expect(s.timeline).toEqual([]); + expect(Object.isFrozen(s.timeline)).toBe(true); + }); + + it('initializes stuckNotified as false', () => { + const s = newSession(); + expect(s.stuckNotified).toBe(false); + }); + + it('initializes telemetry as null', () => { + const s = newSession(); + expect(s.telemetry).toBeNull(); + }); +}); + +// ── onData stuck re-arming (A5 / §3.4) ─────────────────────────────────────── +describe('onData stuck re-arming', () => { + it('resets stuckNotified to false on any output', () => { + const s = newSession(); + s.stuckNotified = true; // simulate a stuck alert having fired this round + (s.pty as MockIPty).emitData('new output'); + expect(s.stuckNotified).toBe(false); + }); + + it('resets claudeStatus from stuck→working and broadcasts when output arrives', () => { + const s = newSession(); + const ws = createMockWs(); + attachWs(s, ws); + ws.sent.length = 0; + + s.claudeStatus = 'stuck'; + (s.pty as MockIPty).emitData('recovered output'); + + expect(s.claudeStatus).toBe('working'); + const msgs = received(ws); + const statusMsg = msgs.find((m) => m.type === 'status'); + expect(statusMsg).toEqual({ type: 'status', status: 'working' }); + }); + + it('does NOT emit a status message when claudeStatus is not stuck', () => { + const s = newSession(); + const ws = createMockWs(); + attachWs(s, ws); + ws.sent.length = 0; + + s.claudeStatus = 'working'; // not stuck + (s.pty as MockIPty).emitData('normal chunk'); + + const msgs = received(ws); + const statusMsgs = msgs.filter((m) => m.type === 'status'); + expect(statusMsgs).toHaveLength(0); + }); + + it('does NOT emit a status message for idle/unknown/waiting (only stuck → working)', () => { + for (const status of ['idle', 'unknown', 'waiting'] as const) { + nextPty = createMockPty(); + const s = createSession(CFG, DIMS, 1_000, () => {}); + const ws = createMockWs(); + attachWs(s, ws); + ws.sent.length = 0; + + s.claudeStatus = status; + (s.pty as MockIPty).emitData('chunk'); + + const msgs = received(ws); + const statusMsgs = msgs.filter((m) => m.type === 'status'); + expect(statusMsgs).toHaveLength(0); + } + }); + + it('broadcasts stuck reset even with no clients attached (no-op, does not throw)', () => { + const s = newSession(); + // no clients attached + s.claudeStatus = 'stuck'; + expect(() => (s.pty as MockIPty).emitData('silent recovery')).not.toThrow(); + expect(s.claudeStatus).toBe('working'); + expect(s.stuckNotified).toBe(false); + }); + + it('output message is still broadcast after stuck re-arm', () => { + const s = newSession(); + const ws = createMockWs(); + attachWs(s, ws); + ws.sent.length = 0; + + s.claudeStatus = 'stuck'; + (s.pty as MockIPty).emitData('hello after stuck'); + + const msgs = received(ws); + const outputMsgs = msgs.filter((m) => m.type === 'output'); + expect(outputMsgs).toHaveLength(1); + expect(outputMsgs[0]).toEqual({ type: 'output', data: 'hello after stuck' }); + }); +}); diff --git a/test/session/timeline.test.ts b/test/session/timeline.test.ts new file mode 100644 index 0000000..549b957 --- /dev/null +++ b/test/session/timeline.test.ts @@ -0,0 +1,349 @@ +import { describe, it, expect } from 'vitest'; +import { + sanitizeField, + deriveClass, + deriveLabel, + makeTimelineEvent, + appendEvent, +} from '../../src/session/timeline.js'; +import type { TimelineEvent } from '../../src/types.js'; + +/** + * N-timeline tests — src/session/timeline.ts + * + * Covers: + * - sanitizeField: control-char strip + truncation (SEC-H6) + * - deriveClass: hook name → semantic TimelineClass (§3.1) + * - deriveLabel: human-language label per event type + * - makeTimelineEvent: whitelist gate + sanitize + compose + * - appendEvent: immutability + bounded eviction (SEC-M8) + */ + +// ── helpers ────────────────────────────────────────────────────────────────── + +function makeEvent(overrides: Partial = {}): TimelineEvent { + return { + at: 1000, + class: 'tool', + label: 'ran Bash', + ...overrides, + }; +} + +// ── sanitizeField ───────────────────────────────────────────────────────────── + +describe('sanitizeField', () => { + it('returns the string unchanged when it has no control chars and is under max', () => { + expect(sanitizeField('Bash')).toBe('Bash'); + }); + + it('strips all control characters (0x00–0x1f)', () => { + // NUL, LF, CR, ESC, tab should all be removed + const input = '\x00Bash\x09script\x0a\x0d\x1b[1m'; + expect(sanitizeField(input)).toBe('Bashscript[1m'); + }); + + it('strips null bytes embedded in the string', () => { + expect(sanitizeField('he\x00llo')).toBe('hello'); + }); + + it('truncates to the default max of 200 characters', () => { + const long = 'a'.repeat(300); + const result = sanitizeField(long); + expect(result.length).toBe(200); + }); + + it('truncates to a custom max', () => { + const result = sanitizeField('abcdef', 3); + expect(result).toBe('abc'); + }); + + it('handles an empty string', () => { + expect(sanitizeField('')).toBe(''); + }); + + it('does not truncate strings exactly at the max', () => { + const exact = 'x'.repeat(200); + expect(sanitizeField(exact).length).toBe(200); + }); + + it('strips control chars before truncating (strip-then-truncate)', () => { + // 10 control chars + 5 regular chars; after strip only 5 remain → under max + const withControls = '\x01'.repeat(10) + 'hello'; + expect(sanitizeField(withControls, 8)).toBe('hello'); + }); +}); + +// ── deriveClass ─────────────────────────────────────────────────────────────── + +describe('deriveClass', () => { + it('maps PreToolUse → "tool"', () => { + expect(deriveClass('PreToolUse')).toBe('tool'); + }); + + it('maps PostToolUse → "tool"', () => { + expect(deriveClass('PostToolUse')).toBe('tool'); + }); + + it('maps PermissionRequest → "waiting"', () => { + expect(deriveClass('PermissionRequest')).toBe('waiting'); + }); + + it('maps Notification → "waiting"', () => { + expect(deriveClass('Notification')).toBe('waiting'); + }); + + it('maps Stop → "done"', () => { + expect(deriveClass('Stop')).toBe('done'); + }); + + it('maps SessionEnd → "done"', () => { + expect(deriveClass('SessionEnd')).toBe('done'); + }); + + it('maps UserPromptSubmit → "user"', () => { + expect(deriveClass('UserPromptSubmit')).toBe('user'); + }); + + it('returns "tool" as a safe fallback for unknown event names', () => { + expect(deriveClass('SomeUnknownEvent')).toBe('tool'); + }); +}); + +// ── deriveLabel ─────────────────────────────────────────────────────────────── + +describe('deriveLabel', () => { + describe('PreToolUse', () => { + it('returns a label that includes the tool name when provided', () => { + const label = deriveLabel('PreToolUse', 'Bash'); + expect(label).toContain('Bash'); + }); + + it('returns a generic label when toolName is absent', () => { + const label = deriveLabel('PreToolUse', undefined); + expect(label.length).toBeGreaterThan(0); + }); + }); + + describe('PostToolUse', () => { + it('returns a "ran" label for execution tools (Bash)', () => { + const label = deriveLabel('PostToolUse', 'Bash'); + expect(label.toLowerCase()).toContain('bash'); + }); + + it('returns an "edit" flavour label for Edit tool', () => { + const label = deriveLabel('PostToolUse', 'Edit'); + // Should be something like "edited file" or "ran Edit" — contract just says it's human + expect(label.length).toBeGreaterThan(0); + }); + + it('returns a generic label when toolName is absent', () => { + const label = deriveLabel('PostToolUse', undefined); + expect(label.length).toBeGreaterThan(0); + }); + }); + + describe('waiting events', () => { + it('returns an approval-flavoured label for PermissionRequest', () => { + const label = deriveLabel('PermissionRequest', undefined); + expect(label.length).toBeGreaterThan(0); + expect(label.toLowerCase()).toMatch(/wait|approv|permiss/); + }); + + it('returns an approval-flavoured label for Notification', () => { + const label = deriveLabel('Notification', undefined); + expect(label.toLowerCase()).toMatch(/wait|approv|permiss/); + }); + }); + + describe('done events', () => { + it('returns a "done" label for Stop', () => { + expect(deriveLabel('Stop', undefined)).toMatch(/done|stop|end/i); + }); + + it('returns a "done" label for SessionEnd', () => { + expect(deriveLabel('SessionEnd', undefined)).toMatch(/done|stop|end/i); + }); + }); + + describe('user input', () => { + it('returns a user-input label for UserPromptSubmit', () => { + const label = deriveLabel('UserPromptSubmit', undefined); + expect(label.toLowerCase()).toMatch(/user|input|prompt/); + }); + }); +}); + +// ── makeTimelineEvent ───────────────────────────────────────────────────────── + +describe('makeTimelineEvent', () => { + it('returns null for an unknown event name (whitelist guard)', () => { + expect(makeTimelineEvent('UnknownHookType', undefined, 1000)).toBeNull(); + }); + + it('returns null for an empty string event name', () => { + expect(makeTimelineEvent('', undefined, 1000)).toBeNull(); + }); + + it('returns a TimelineEvent for PreToolUse', () => { + const ev = makeTimelineEvent('PreToolUse', 'Bash', 1234); + expect(ev).not.toBeNull(); + expect(ev!.at).toBe(1234); + expect(ev!.class).toBe('tool'); + expect(ev!.toolName).toBe('Bash'); + expect(ev!.label.length).toBeGreaterThan(0); + }); + + it('returns a TimelineEvent for PostToolUse', () => { + const ev = makeTimelineEvent('PostToolUse', 'Bash', 9999); + expect(ev).not.toBeNull(); + expect(ev!.class).toBe('tool'); + expect(ev!.toolName).toBe('Bash'); + }); + + it('returns a TimelineEvent for PermissionRequest', () => { + const ev = makeTimelineEvent('PermissionRequest', undefined, 5000); + expect(ev).not.toBeNull(); + expect(ev!.class).toBe('waiting'); + expect(ev!.toolName).toBeUndefined(); + }); + + it('returns a TimelineEvent for Stop', () => { + const ev = makeTimelineEvent('Stop', undefined, 7000); + expect(ev).not.toBeNull(); + expect(ev!.class).toBe('done'); + }); + + it('returns a TimelineEvent for SessionEnd', () => { + const ev = makeTimelineEvent('SessionEnd', undefined, 8000); + expect(ev).not.toBeNull(); + expect(ev!.class).toBe('done'); + }); + + it('returns a TimelineEvent for UserPromptSubmit', () => { + const ev = makeTimelineEvent('UserPromptSubmit', undefined, 6000); + expect(ev).not.toBeNull(); + expect(ev!.class).toBe('user'); + }); + + it('returns a TimelineEvent for Notification', () => { + const ev = makeTimelineEvent('Notification', undefined, 2000); + expect(ev).not.toBeNull(); + expect(ev!.class).toBe('waiting'); + }); + + it('sanitizes control characters in toolName (SEC-H6)', () => { + const ev = makeTimelineEvent('PostToolUse', 'Ba\x01sh\x1bscript', 1000); + expect(ev).not.toBeNull(); + expect(ev!.toolName).toBe('Bashscript'); + }); + + it('truncates toolName to 200 characters (SEC-H6)', () => { + const longName = 'a'.repeat(250); + const ev = makeTimelineEvent('PostToolUse', longName, 1000); + expect(ev).not.toBeNull(); + expect(ev!.toolName!.length).toBe(200); + }); + + it('omits toolName from the result when toolName is undefined', () => { + const ev = makeTimelineEvent('Stop', undefined, 1000); + expect(ev).not.toBeNull(); + expect(ev!.toolName).toBeUndefined(); + }); +}); + +// ── appendEvent ─────────────────────────────────────────────────────────────── + +describe('appendEvent', () => { + it('appends to an empty array', () => { + const ev = makeEvent({ at: 100 }); + const result = appendEvent([], ev, 10); + expect(result).toHaveLength(1); + expect(result[0]).toBe(ev); + }); + + it('appends to a non-empty array', () => { + const ev1 = makeEvent({ at: 100 }); + const ev2 = makeEvent({ at: 200 }); + const result = appendEvent([ev1], ev2, 10); + expect(result).toHaveLength(2); + expect(result[1]).toBe(ev2); + }); + + it('does not mutate the input array', () => { + const original: readonly TimelineEvent[] = [makeEvent({ at: 1 })]; + const ev = makeEvent({ at: 2 }); + appendEvent(original, ev, 10); + expect(original).toHaveLength(1); + }); + + it('returns a new array reference (immutability)', () => { + const original: readonly TimelineEvent[] = [makeEvent({ at: 1 })]; + const ev = makeEvent({ at: 2 }); + const result = appendEvent(original, ev, 10); + expect(result).not.toBe(original); + }); + + it('evicts the oldest events when length exceeds maxLen', () => { + const events: readonly TimelineEvent[] = [ + makeEvent({ at: 1 }), + makeEvent({ at: 2 }), + makeEvent({ at: 3 }), + ]; + const ev = makeEvent({ at: 4 }); + // maxLen = 3, so appending a 4th must evict the oldest (at=1) + const result = appendEvent(events, ev, 3); + expect(result).toHaveLength(3); + expect(result[0]!.at).toBe(2); + expect(result[2]!.at).toBe(4); + }); + + it('enforces the ring cap: append MAX+10 events → length ≤ MAX (SEC-M8)', () => { + const MAX = 5; + let events: readonly TimelineEvent[] = []; + for (let i = 0; i < MAX + 10; i++) { + events = appendEvent(events, makeEvent({ at: i }), MAX); + } + expect(events.length).toBeLessThanOrEqual(MAX); + }); + + it('keeps only the newest events when trimming (oldest are evicted first)', () => { + const MAX = 3; + let events: readonly TimelineEvent[] = []; + for (let i = 0; i < 10; i++) { + events = appendEvent(events, makeEvent({ at: i }), MAX); + } + // Should have the last 3 events: at = 7, 8, 9 + expect(events.map((e) => e.at)).toEqual([7, 8, 9]); + }); + + it('handles maxLen of 1 (only the newest event survives)', () => { + const ev1 = makeEvent({ at: 1 }); + const ev2 = makeEvent({ at: 2 }); + const result = appendEvent([ev1], ev2, 1); + expect(result).toHaveLength(1); + expect(result[0]!.at).toBe(2); + }); +}); + +// ── integration: each TimelineClass has at least one example ───────────────── + +describe('TimelineClass coverage (AC-A4.4)', () => { + const WHITELISTED_EVENTS: Array<{ event: string; toolName?: string; expectedClass: string }> = [ + { event: 'PreToolUse', toolName: 'Bash', expectedClass: 'tool' }, + { event: 'PostToolUse', toolName: 'Read', expectedClass: 'tool' }, + { event: 'PermissionRequest', expectedClass: 'waiting' }, + { event: 'Notification', expectedClass: 'waiting' }, + { event: 'Stop', expectedClass: 'done' }, + { event: 'SessionEnd', expectedClass: 'done' }, + { event: 'UserPromptSubmit', expectedClass: 'user' }, + ]; + + for (const { event, toolName, expectedClass } of WHITELISTED_EVENTS) { + it(`${event} → class '${expectedClass}'`, () => { + const ev = makeTimelineEvent(event, toolName, Date.now()); + expect(ev).not.toBeNull(); + expect(ev!.class).toBe(expectedClass); + }); + } +}); diff --git a/test/setup-hooks.test.ts b/test/setup-hooks.test.ts new file mode 100644 index 0000000..cf87eaa --- /dev/null +++ b/test/setup-hooks.test.ts @@ -0,0 +1,436 @@ +/** + * test/setup-hooks.test.ts — T-hooks-installer tests + * + * Tests the exported pure functions from scripts/setup-hooks.mjs: + * - computeMaxTimeSec: L5/SEC-M4 --max-time computation + * - buildPermCommand: held-permission curl with dynamic max-time + * - buildNtfyCommand: ntfy bridge curl (SEC-C6: no token literal) + * - buildStatusLineCommand: absolute path to statusline.mjs + * - FF_EVENTS: must include PostToolUse (H1/AC-A4.5) + * - applyHooks: integration — install/remove idempotency, ntfy, statusLine + * + * Acceptance criteria covered: + * AC-A1.7 ntfy command has correct priority; settings.json has NO token literal + * AC-A4.5 FF_EVENTS contains PostToolUse + * AC-B2.1 statusLine segment: present after install, idempotent, --remove cleans + * SEC-C6 token not in settings.json/argv + * SEC-M4 --max-time > permTimeoutMs/1000 + * L5 --max-time computed from PERM_TIMEOUT_MS env, not hardcoded + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore — vitest handles .mjs; tsc does not check test/ files +import { + computeMaxTimeSec, + buildPermCommand, + buildNtfyCommand, + buildStatusLineCommand, + FF_EVENTS, + FF_COMMAND, + PERM_COMMAND, + MARKER, + STATUSLINE_MARKER, + applyHooks, +} from '../scripts/setup-hooks.mjs' + +// Path to the scripts directory (for absolute path assertions) +const SCRIPTS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../scripts') + +// ── computeMaxTimeSec ──────────────────────────────────────────────────────── + +describe('computeMaxTimeSec', () => { + it('adds CURL_BUFFER_SEC (5) on top of ceil(permTimeoutMs/1000)', () => { + // 300000ms = 300s + 5 = 305 + expect(computeMaxTimeSec(300_000)).toBe(305) + }) + + it('handles custom permTimeoutMs correctly', () => { + // 60000ms = 60s + 5 = 65 + expect(computeMaxTimeSec(60_000)).toBe(65) + // 90001ms = ceil(90.001) = 91 + 5 = 96 + expect(computeMaxTimeSec(90_001)).toBe(96) + }) + + it('uses default (300000ms) when permTimeoutMs is 0 (invalid)', () => { + expect(computeMaxTimeSec(0)).toBe(305) + }) + + it('uses default when permTimeoutMs is negative', () => { + expect(computeMaxTimeSec(-1000)).toBe(305) + }) + + it('uses default when called with no arguments', () => { + expect(computeMaxTimeSec()).toBe(305) + }) + + it('returns value > permTimeoutMs/1000 (L5/SEC-M4)', () => { + const permTimeoutMs = 180_000 + const maxTime = computeMaxTimeSec(permTimeoutMs) + expect(maxTime).toBeGreaterThan(permTimeoutMs / 1000) + }) +}) + +// ── buildPermCommand ───────────────────────────────────────────────────────── + +describe('buildPermCommand', () => { + it('includes the provided maxTimeSec in --max-time', () => { + const cmd = buildPermCommand(305) + expect(cmd).toContain('--max-time 305') + }) + + it('does NOT contain the old hardcoded value 350', () => { + const cmd = buildPermCommand(305) + expect(cmd).not.toContain('350') + }) + + it('references WEBTERM_HOOK_URL/permission', () => { + const cmd = buildPermCommand(305) + expect(cmd).toContain('WEBTERM_HOOK_URL') + expect(cmd).toContain('/permission') + }) + + it('sends body via --data-binary @- (reads stdin)', () => { + const cmd = buildPermCommand(305) + expect(cmd).toContain('--data-binary @-') + }) +}) + +// ── buildNtfyCommand ───────────────────────────────────────────────────────── + +describe('buildNtfyCommand', () => { + it('high-priority command contains "Priority: high"', () => { + const cmd = buildNtfyCommand('high') + expect(cmd).toContain('Priority: high') + }) + + it('low-priority command contains "Priority: low"', () => { + const cmd = buildNtfyCommand('low') + expect(cmd).toContain('Priority: low') + }) + + it('uses shell variable references for URL, TOPIC, and TOKEN (SEC-C6)', () => { + const cmd = buildNtfyCommand('high') + expect(cmd).toContain('WEBTERM_NTFY_URL') + expect(cmd).toContain('WEBTERM_NTFY_TOPIC') + expect(cmd).toContain('WEBTERM_NTFY_TOKEN') + }) + + it('does NOT contain any literal token value (SEC-C6)', () => { + // The token must be referenced via env var, never as a literal string + const cmd = buildNtfyCommand('high') + // No hardcoded token patterns (Bearer followed by non-$ string) + expect(cmd).not.toMatch(/Bearer [^$]/) + }) + + it('contains MARKER (WEBTERM_HOOK_URL) for cleanup idempotency', () => { + // The no-op guard references WEBTERM_HOOK_URL, ensuring cleanup logic catches it + const cmd = buildNtfyCommand('high') + expect(cmd).toContain(MARKER) // MARKER = 'WEBTERM_HOOK_URL' + }) + + it('is a no-op outside web-terminal (guard: [ -n "$WEBTERM_HOOK_URL" ])', () => { + const cmd = buildNtfyCommand('low') + expect(cmd).toContain('WEBTERM_HOOK_URL') + // Command has a conditional that makes it a no-op when WEBTERM_HOOK_URL is absent + expect(cmd).toMatch(/\[ -n .+WEBTERM_HOOK_URL/) + }) + + it('always ends with || true (fire-and-forget, no hook failure)', () => { + expect(buildNtfyCommand('high')).toContain('|| true') + expect(buildNtfyCommand('low')).toContain('|| true') + }) +}) + +// ── buildStatusLineCommand ──────────────────────────────────────────────────── + +describe('buildStatusLineCommand', () => { + it('includes the STATUSLINE_MARKER (statusline.mjs)', () => { + const cmd = buildStatusLineCommand(SCRIPTS_DIR) + expect(cmd).toContain(STATUSLINE_MARKER) // 'statusline.mjs' + }) + + it('uses an absolute path to statusline.mjs', () => { + const cmd = buildStatusLineCommand(SCRIPTS_DIR) + // Command should contain an absolute path + const scriptPath = path.join(SCRIPTS_DIR, 'statusline.mjs') + expect(cmd).toContain(scriptPath) + }) + + it('starts with "node " (runs the script with Node)', () => { + const cmd = buildStatusLineCommand(SCRIPTS_DIR) + expect(cmd).toMatch(/^node /) + }) +}) + +// ── FF_EVENTS ──────────────────────────────────────────────────────────────── + +describe('FF_EVENTS', () => { + it('contains PostToolUse (H1/AC-A4.5)', () => { + expect(FF_EVENTS).toContain('PostToolUse') + }) + + it('contains PreToolUse', () => { + expect(FF_EVENTS).toContain('PreToolUse') + }) + + it('contains Stop and SessionEnd (DONE signals)', () => { + expect(FF_EVENTS).toContain('Stop') + expect(FF_EVENTS).toContain('SessionEnd') + }) + + it('contains UserPromptSubmit', () => { + expect(FF_EVENTS).toContain('UserPromptSubmit') + }) + + it('contains Notification', () => { + expect(FF_EVENTS).toContain('Notification') + }) +}) + +// ── FF_COMMAND / PERM_COMMAND constants ─────────────────────────────────────── + +describe('FF_COMMAND', () => { + it('contains MARKER for cleanup identification', () => { + expect(FF_COMMAND).toContain(MARKER) + }) + + it('is a fire-and-forget (redirects to /dev/null, || true)', () => { + expect(FF_COMMAND).toContain('>/dev/null') + expect(FF_COMMAND).toContain('|| true') + }) +}) + +describe('PERM_COMMAND (module-level constant)', () => { + it('does NOT contain the old hardcoded 350', () => { + // L5: --max-time must be computed, not a bare literal 350 + expect(PERM_COMMAND).not.toContain(' 350 ') + expect(PERM_COMMAND).not.toMatch(/--max-time 350\b/) + }) + + it('contains --max-time with a computed numeric value', () => { + expect(PERM_COMMAND).toMatch(/--max-time \d+/) + }) + + it('computed --max-time > default permTimeoutMs/1000 (SEC-M4)', () => { + const match = PERM_COMMAND.match(/--max-time (\d+)/) + expect(match).toBeTruthy() + const maxTime = Number(match![1]) + // Default permTimeoutMs = 300000ms = 300s; max-time must be > 300 + expect(maxTime).toBeGreaterThan(300) + }) +}) + +// ── applyHooks (integration tests) ─────────────────────────────────────────── + +describe('applyHooks', () => { + let tmpDir: string + let settingsPath: string + + beforeEach(() => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'webterm-setup-hooks-test-')) + settingsPath = path.join(tmpDir, 'settings.json') + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('creates settings.json when it does not exist', () => { + applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR }) + expect(existsSync(settingsPath)).toBe(true) + }) + + it('installs hook entry for every FF_EVENT', () => { + applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR }) + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) + for (const ev of FF_EVENTS) { + expect(settings.hooks[ev], `hook for ${ev} should exist`).toBeDefined() + expect(Array.isArray(settings.hooks[ev])).toBe(true) + expect(settings.hooks[ev].length).toBeGreaterThanOrEqual(1) + } + }) + + it('installs PostToolUse hook (H1/AC-A4.5)', () => { + applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR }) + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) + expect(settings.hooks['PostToolUse']).toBeDefined() + const groups = settings.hooks['PostToolUse'] + expect(groups.some((g: { hooks?: Array<{ command?: string }> }) => + g.hooks?.some((h) => typeof h.command === 'string' && h.command.includes(MARKER)) + )).toBe(true) + }) + + it('installs PermissionRequest hook with computed --max-time (L5)', () => { + applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 60_000, scriptDir: SCRIPTS_DIR }) + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) + expect(settings.hooks['PermissionRequest']).toBeDefined() + const allCommands: string[] = settings.hooks['PermissionRequest'] + .flatMap((g: { hooks?: Array<{ command?: string }> }) => + g.hooks?.map((h) => h.command ?? '') ?? [] + ) + const permCmd = allCommands.find((c) => c.includes('/permission')) + expect(permCmd).toBeDefined() + // permTimeoutMs=60000 → ceil(60)+5=65 + expect(permCmd).toContain('--max-time 65') + // must NOT contain old hardcoded value + expect(permCmd).not.toContain(' 350 ') + }) + + it('installs statusLine handler (B2/AC-B2.1)', () => { + applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR }) + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) + expect(settings.statusLine).toBeDefined() + expect(settings.statusLine).toContain(STATUSLINE_MARKER) + expect(settings.statusLine).toContain(path.join(SCRIPTS_DIR, 'statusline.mjs')) + }) + + it('is idempotent: running twice produces same result (B2/AC-B2.1)', () => { + const opts = { settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR } + applyHooks(opts) + const first = JSON.parse(readFileSync(settingsPath, 'utf8')) + applyHooks(opts) + const second = JSON.parse(readFileSync(settingsPath, 'utf8')) + expect(second).toEqual(first) + }) + + it('--remove removes all web-terminal hook entries (AC-B2.1)', () => { + applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR }) + applyHooks({ settingsPath, remove: true, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR }) + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) + // All hook events added by us should be gone + for (const ev of [...FF_EVENTS, 'PermissionRequest']) { + const groups = settings.hooks?.[ev] + if (groups) { + const hasOurHook = groups.some((g: { hooks?: Array<{ command?: string }> }) => + g.hooks?.some((h) => typeof h.command === 'string' && h.command.includes(MARKER)) + ) + expect(hasOurHook).toBe(false) + } + } + }) + + it('--remove removes statusLine if it was ours (AC-B2.1)', () => { + applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR }) + applyHooks({ settingsPath, remove: true, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR }) + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) + expect(settings.statusLine).toBeUndefined() + }) + + it('preserves user non-web-terminal hooks during install', () => { + const existing = { hooks: { SomeOtherEvent: [{ hooks: [{ type: 'command', command: 'echo hi' }] }] } } + writeFileSync(settingsPath, JSON.stringify(existing)) + applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR }) + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) + expect(settings.hooks['SomeOtherEvent']).toBeDefined() + expect(settings.hooks['SomeOtherEvent'][0].hooks[0].command).toBe('echo hi') + }) + + it('preserves user non-web-terminal hooks during --remove', () => { + applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR }) + const settingsAfterInstall = JSON.parse(readFileSync(settingsPath, 'utf8')) + // Simulate user adding their own hook + settingsAfterInstall.hooks['UserOwnEvent'] = [{ hooks: [{ type: 'command', command: 'echo mine' }] }] + writeFileSync(settingsPath, JSON.stringify(settingsAfterInstall)) + applyHooks({ settingsPath, remove: true, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR }) + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) + expect(settings.hooks?.['UserOwnEvent']).toBeDefined() + }) + + it('preserves user statusLine if not ours', () => { + const existing = { statusLine: 'node my-custom-statusline.js' } + writeFileSync(settingsPath, JSON.stringify(existing)) + applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR }) + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) + // User's statusLine was NOT overwritten (it doesn't contain STATUSLINE_MARKER) + expect(settings.statusLine).toBe('node my-custom-statusline.js') + }) + + it('creates a .webterm-bak before writing when settings exists', () => { + writeFileSync(settingsPath, JSON.stringify({ hooks: {} })) + applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR }) + expect(existsSync(`${settingsPath}.webterm-bak`)).toBe(true) + }) + + // ── ntfy bridge tests (H3, SEC-C6) ───────────────────────────────────────── + + it('without ntfy: PermissionRequest has exactly 1 hook group', () => { + applyHooks({ settingsPath, remove: false, hasNtfy: false, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR }) + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) + expect(settings.hooks['PermissionRequest'].length).toBe(1) + }) + + it('with ntfy: PermissionRequest has 2 hook groups (main + ntfy high)', () => { + applyHooks({ settingsPath, remove: false, hasNtfy: true, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR }) + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) + expect(settings.hooks['PermissionRequest'].length).toBe(2) + }) + + it('with ntfy: PermissionRequest ntfy group has high priority (AC-A1.7)', () => { + applyHooks({ settingsPath, remove: false, hasNtfy: true, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR }) + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) + const allCommands: string[] = settings.hooks['PermissionRequest'] + .flatMap((g: { hooks?: Array<{ command?: string }> }) => + g.hooks?.map((h) => h.command ?? '') ?? [] + ) + const ntfyCmd = allCommands.find((c) => c.includes('WEBTERM_NTFY_URL')) + expect(ntfyCmd).toBeDefined() + expect(ntfyCmd).toContain('Priority: high') + }) + + it('with ntfy: Stop has ntfy group with low priority (AC-A1.7)', () => { + applyHooks({ settingsPath, remove: false, hasNtfy: true, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR }) + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) + const allCommands: string[] = settings.hooks['Stop'] + .flatMap((g: { hooks?: Array<{ command?: string }> }) => + g.hooks?.map((h) => h.command ?? '') ?? [] + ) + const ntfyCmd = allCommands.find((c) => c.includes('WEBTERM_NTFY_URL')) + expect(ntfyCmd).toBeDefined() + expect(ntfyCmd).toContain('Priority: low') + }) + + it('with ntfy: SessionEnd has ntfy group with low priority', () => { + applyHooks({ settingsPath, remove: false, hasNtfy: true, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR }) + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) + const allCommands: string[] = settings.hooks['SessionEnd'] + .flatMap((g: { hooks?: Array<{ command?: string }> }) => + g.hooks?.map((h) => h.command ?? '') ?? [] + ) + const ntfyCmd = allCommands.find((c) => c.includes('WEBTERM_NTFY_URL')) + expect(ntfyCmd).toBeDefined() + expect(ntfyCmd).toContain('Priority: low') + }) + + it('with ntfy: settings.json contains NO token literal (SEC-C6/AC-A1.7)', () => { + // Even with ntfy enabled, the token must never appear as a literal + // We simulate having a WEBTERM_NTFY_TOKEN in env at test time; the command + // should reference it as $WEBTERM_NTFY_TOKEN, not the actual value + applyHooks({ settingsPath, remove: false, hasNtfy: true, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR }) + const content = readFileSync(settingsPath, 'utf8') + // The token variable is referenced, not expanded — grep for Bearer pattern + // Ensure no "Bearer " pattern (only "Bearer $WEBTERM_NTFY_TOKEN") + expect(content).not.toMatch(/Bearer [^$"\\]/) + // The $ reference must be present + expect(content).toContain('WEBTERM_NTFY_TOKEN') + }) + + it('with ntfy: --remove removes ntfy hook groups too', () => { + applyHooks({ settingsPath, remove: false, hasNtfy: true, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR }) + applyHooks({ settingsPath, remove: true, hasNtfy: true, permTimeoutMs: 300_000, scriptDir: SCRIPTS_DIR }) + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) + // After remove, no groups with MARKER should remain + for (const ev of Object.keys(settings.hooks ?? {})) { + const groups: Array<{ hooks?: Array<{ command?: string }> }> = settings.hooks[ev] ?? [] + const hasOurHook = groups.some((g) => + g.hooks?.some((h) => typeof h.command === 'string' && h.command.includes(MARKER)) + ) + expect(hasOurHook).toBe(false) + } + }) +}) diff --git a/test/statusline.test.ts b/test/statusline.test.ts new file mode 100644 index 0000000..4586e45 --- /dev/null +++ b/test/statusline.test.ts @@ -0,0 +1,262 @@ +/** + * test/statusline.test.ts — N-statusline-script tests (B2) + * + * Tests the pure exported functions from scripts/statusline.mjs: + * - parseRaw: parse raw Claude Code statusLine JSON + * - buildSummary: format one-line status string + * - postToServer: POST to /hook/status (mocked fetch) + * + * readStdin is not tested here (it wraps process.stdin which blocks in tests). + * Integration-level acceptance (actual POST to /hook/status) is covered by V-integration. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +// vitest/esbuild handles .mjs imports; tsc does not check test/ files +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import { parseRaw, buildSummary, postToServer } from '../scripts/statusline.mjs' + +// ── parseRaw ────────────────────────────────────────────────────────────────── + +describe('parseRaw', () => { + it('returns null for invalid JSON', () => { + expect(parseRaw('not-json')).toBeNull() + expect(parseRaw('')).toBeNull() + expect(parseRaw('{')).toBeNull() + }) + + it('returns null for non-object JSON values', () => { + expect(parseRaw('null')).toBeNull() + expect(parseRaw('"string"')).toBeNull() + expect(parseRaw('42')).toBeNull() + expect(parseRaw('true')).toBeNull() + }) + + it('returns null for JSON arrays', () => { + expect(parseRaw('[]')).toBeNull() + expect(parseRaw('[1,2,3]')).toBeNull() + }) + + it('returns an object (not null) for empty JSON object', () => { + const result = parseRaw('{}') + expect(result).not.toBeNull() + expect(result).toEqual({ contextUsedPct: undefined, costUsd: undefined, model: undefined }) + }) + + it('extracts contextUsedPct from context_window.used_percentage', () => { + const raw = JSON.stringify({ context_window: { used_percentage: 45.7 } }) + const result = parseRaw(raw) + expect(result?.contextUsedPct).toBe(45.7) + }) + + it('returns undefined contextUsedPct when context_window is missing', () => { + const result = parseRaw('{}') + expect(result?.contextUsedPct).toBeUndefined() + }) + + it('returns undefined contextUsedPct when used_percentage is not a number', () => { + const raw = JSON.stringify({ context_window: { used_percentage: '45%' } }) + expect(parseRaw(raw)?.contextUsedPct).toBeUndefined() + }) + + it('returns undefined contextUsedPct for Infinity and NaN', () => { + const raw = JSON.stringify({ context_window: { used_percentage: null } }) + expect(parseRaw(raw)?.contextUsedPct).toBeUndefined() + }) + + it('returns undefined contextUsedPct when context_window is not an object', () => { + const raw = JSON.stringify({ context_window: 'bad' }) + expect(parseRaw(raw)?.contextUsedPct).toBeUndefined() + }) + + it('extracts costUsd from cost.total_cost_usd', () => { + const raw = JSON.stringify({ cost: { total_cost_usd: 0.532 } }) + expect(parseRaw(raw)?.costUsd).toBe(0.532) + }) + + it('returns undefined costUsd when cost is missing', () => { + expect(parseRaw('{}')?.costUsd).toBeUndefined() + }) + + it('returns undefined costUsd for non-numeric values', () => { + const raw = JSON.stringify({ cost: { total_cost_usd: 'expensive' } }) + expect(parseRaw(raw)?.costUsd).toBeUndefined() + }) + + it('returns undefined costUsd when cost is not an object', () => { + const raw = JSON.stringify({ cost: 42 }) + expect(parseRaw(raw)?.costUsd).toBeUndefined() + }) + + it('extracts model from model.display_name', () => { + const raw = JSON.stringify({ model: { display_name: 'claude-opus-4-5' } }) + expect(parseRaw(raw)?.model).toBe('claude-opus-4-5') + }) + + it('extracts model from model string (fallback)', () => { + const raw = JSON.stringify({ model: 'claude-sonnet' }) + expect(parseRaw(raw)?.model).toBe('claude-sonnet') + }) + + it('returns undefined model when model is missing', () => { + expect(parseRaw('{}')?.model).toBeUndefined() + }) + + it('returns undefined model for empty model string', () => { + const raw = JSON.stringify({ model: '' }) + expect(parseRaw(raw)?.model).toBeUndefined() + }) + + it('returns undefined model when model.display_name is not a string', () => { + const raw = JSON.stringify({ model: { display_name: 42 } }) + expect(parseRaw(raw)?.model).toBeUndefined() + }) + + it('truncates model to 100 characters', () => { + const longModel = 'a'.repeat(150) + const raw = JSON.stringify({ model: { display_name: longModel } }) + expect(parseRaw(raw)?.model).toHaveLength(100) + }) + + it('extracts all three fields together (full statusLine JSON)', () => { + const raw = JSON.stringify({ + context_window: { used_percentage: 60.0 }, + cost: { total_cost_usd: 1.23, total_lines_added: 100, total_lines_removed: 20 }, + model: { display_name: 'claude-opus-4-5' }, + effort: { level: 'high' }, + }) + const result = parseRaw(raw) + expect(result).toEqual({ + contextUsedPct: 60.0, + costUsd: 1.23, + model: 'claude-opus-4-5', + }) + }) + + it('handles partial fields gracefully (only model provided)', () => { + const raw = JSON.stringify({ model: { display_name: 'claude-haiku' } }) + const result = parseRaw(raw) + expect(result?.contextUsedPct).toBeUndefined() + expect(result?.costUsd).toBeUndefined() + expect(result?.model).toBe('claude-haiku') + }) + + it('does not throw on any input (never-throw contract)', () => { + const cases = [null, undefined, 42, [], {}, 'random', '{"a":1}'] + for (const input of cases) { + expect(() => parseRaw(String(input))).not.toThrow() + } + }) +}) + +// ── buildSummary ────────────────────────────────────────────────────────────── + +describe('buildSummary', () => { + it('returns empty string for null parsed', () => { + expect(buildSummary(null)).toBe('') + }) + + it('returns empty string when all fields are undefined', () => { + expect(buildSummary({ contextUsedPct: undefined, costUsd: undefined, model: undefined })).toBe('') + }) + + it('formats contextUsedPct as ctx:XX%', () => { + expect(buildSummary({ contextUsedPct: 45.7 })).toBe('ctx:46%') + }) + + it('rounds contextUsedPct to nearest integer', () => { + expect(buildSummary({ contextUsedPct: 45.4 })).toBe('ctx:45%') + expect(buildSummary({ contextUsedPct: 99.9 })).toBe('ctx:100%') + expect(buildSummary({ contextUsedPct: 0 })).toBe('ctx:0%') + }) + + it('formats costUsd as $X.XX', () => { + expect(buildSummary({ costUsd: 0.532 })).toBe('$0.53') + expect(buildSummary({ costUsd: 1.0 })).toBe('$1.00') + expect(buildSummary({ costUsd: 12.345 })).toBe('$12.35') + }) + + it('formats model as plain string', () => { + expect(buildSummary({ model: 'claude-opus-4-5' })).toBe('claude-opus-4-5') + }) + + it('combines all fields with " · " separator', () => { + const result = buildSummary({ contextUsedPct: 45, costUsd: 0.53, model: 'claude-opus' }) + expect(result).toBe('ctx:45% · $0.53 · claude-opus') + }) + + it('combines only present fields (ctx + model, no cost)', () => { + const result = buildSummary({ contextUsedPct: 20, model: 'claude-haiku' }) + expect(result).toBe('ctx:20% · claude-haiku') + }) + + it('combines only present fields (cost + model, no ctx)', () => { + const result = buildSummary({ costUsd: 0.01, model: 'claude-sonnet' }) + expect(result).toBe('$0.01 · claude-sonnet') + }) + + it('omits empty model string', () => { + expect(buildSummary({ model: '' })).toBe('') + expect(buildSummary({ costUsd: 1.0, model: '' })).toBe('$1.00') + }) +}) + +// ── postToServer ────────────────────────────────────────────────────────────── + +describe('postToServer', () => { + const mockFetch = vi.fn() + + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch) + mockFetch.mockResolvedValue({ ok: true, status: 204 }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() + }) + + it('calls fetch with POST method and correct URL', async () => { + await postToServer('http://127.0.0.1:3000/hook/status', 'session-1', '{}') + expect(mockFetch).toHaveBeenCalledOnce() + const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit] + expect(url).toBe('http://127.0.0.1:3000/hook/status') + expect(opts.method).toBe('POST') + }) + + it('sends Content-Type: application/json header', async () => { + await postToServer('http://127.0.0.1:3000/hook/status', 'session-1', '{}') + const [, opts] = mockFetch.mock.calls[0] as [string, RequestInit] + expect((opts.headers as Record)['Content-Type']).toBe('application/json') + }) + + it('sends X-Webterm-Session header with the sessionId', async () => { + await postToServer('http://127.0.0.1:3000/hook/status', 'my-session-id', '{}') + const [, opts] = mockFetch.mock.calls[0] as [string, RequestInit] + expect((opts.headers as Record)['X-Webterm-Session']).toBe('my-session-id') + }) + + it('sends the raw body verbatim', async () => { + const body = '{"context_window":{"used_percentage":50}}' + await postToServer('http://127.0.0.1:3000/hook/status', 's1', body) + const [, opts] = mockFetch.mock.calls[0] as [string, RequestInit] + expect(opts.body).toBe(body) + }) + + it('does not throw when fetch rejects (server down)', async () => { + mockFetch.mockRejectedValue(new Error('ECONNREFUSED')) + await expect(postToServer('http://127.0.0.1:3000/hook/status', 's1', '{}')).resolves.toBeUndefined() + }) + + it('does not throw on network timeout (AbortError)', async () => { + mockFetch.mockRejectedValue(new DOMException('Aborted', 'AbortError')) + await expect(postToServer('http://127.0.0.1:3000/hook/status', 's1', '{}', 1)).resolves.toBeUndefined() + }) + + it('passes an AbortSignal to fetch', async () => { + await postToServer('http://127.0.0.1:3000/hook/status', 's1', '{}') + const [, opts] = mockFetch.mock.calls[0] as [string, RequestInit] + expect(opts.signal).toBeDefined() + }) +}) diff --git a/test/sw-push.test.ts b/test/sw-push.test.ts new file mode 100644 index 0000000..1cc42f0 --- /dev/null +++ b/test/sw-push.test.ts @@ -0,0 +1,190 @@ +/** + * 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): 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 + 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 + 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 + 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() + }) +}) diff --git a/test/tabs.test.ts b/test/tabs.test.ts index f96bbed..0d73a02 100644 --- a/test/tabs.test.ts +++ b/test/tabs.test.ts @@ -8,7 +8,7 @@ * (Phase 1#5), and the v0.6 openProject / home segmented-control wiring (P6). */ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' // ── Mock TerminalSession ────────────────────────────────────────────────────── let constructed = 0 @@ -19,6 +19,11 @@ class FakeTerminalSession { claudeStatus = 'unknown' cwd: string | null = null pendingApproval = false + pendingTool: string | undefined = undefined + /** B4: which gate the held approval is — 'tool' (two buttons) or 'plan' (three). */ + pendingGate: 'tool' | 'plan' | null = null + /** B2: latest statusLine telemetry (single source of truth). */ + telemetry: unknown = null /** Captured so tests can assert initialInput ends with \r (Finding 1). */ initialInput: string | undefined = undefined connect = vi.fn() @@ -39,6 +44,7 @@ class FakeTerminalSession { onStatus?: (s: string) => void onActivity?: () => void onTitle?: (t: string) => void + onTelemetry?: (t: unknown) => void } static instances: FakeTerminalSession[] = [] constructor(opts: { @@ -48,6 +54,7 @@ class FakeTerminalSession { onStatus?: (s: string) => void onActivity?: () => void onTitle?: (t: string) => void + onTelemetry?: (t: unknown) => void [key: string]: unknown }) { constructed += 1 @@ -61,6 +68,37 @@ class FakeTerminalSession { vi.mock('../public/terminal-session.js', () => ({ TerminalSession: FakeTerminalSession })) +// ── Mock the v0.7 panels (push / quick-reply / timeline / voice / gauge) ────── +const renderTelemetryGauge = vi.fn() +vi.mock('../public/preview-grid.js', () => ({ renderTelemetryGauge })) + +const mountPushToggle = vi.fn() +vi.mock('../public/push.js', () => ({ mountPushToggle })) + +const quickReply: { onSend?: (d: string) => void } = {} +const mountQuickReply = vi.fn((_c: HTMLElement, opts: { onSend: (d: string) => void }) => { + quickReply.onSend = opts.onSend + return { dispose: vi.fn() } +}) +vi.mock('../public/quick-reply.js', () => ({ mountQuickReply })) + +const timelineDispose = vi.fn() +const mountTimeline = vi.fn(() => ({ dispose: timelineDispose })) +vi.mock('../public/timeline.js', () => ({ mountTimeline })) + +const voice: { onTranscript?: (t: string) => void; onInterim?: (t: string) => void } = {} +const voiceStart = vi.fn() +const voiceStop = vi.fn() +const createVoiceInput = vi.fn( + (onTranscript: (t: string) => void, opts?: { onInterim?: (t: string) => void }) => { + voice.onTranscript = onTranscript + voice.onInterim = opts?.onInterim + return { start: voiceStart, stop: voiceStop, dispose: vi.fn(), isActive: () => false } + }, +) +const isSpeechSupported = vi.fn(() => true) +vi.mock('../public/voice.js', () => ({ createVoiceInput, isSpeechSupported })) + // ── Mock the launcher (records visibility) ──────────────────────────────────── const launcherVisible = { value: false } const mountLauncher = vi.fn(() => ({ @@ -88,10 +126,21 @@ const { TabApp } = await import('../public/tabs.js') function makeHosts(): { paneHost: HTMLElement; tabBar: HTMLElement } { const paneHost = document.createElement('div') const tabBar = document.createElement('div') - document.body.append(paneHost, tabBar) + // #keybar must exist so the quick-reply row mounts directly above it (A3). + const keybar = document.createElement('div') + keybar.id = 'keybar' + document.body.append(paneHost, tabBar, keybar) return { paneHost, tabBar } } +/** Stub fetch('/config/ui') → { allowAutoMode }. */ +function stubUiConfigFetch(allowAutoMode: boolean): void { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: true, json: async () => ({ allowAutoMode }) })), + ) +} + beforeEach(() => { constructed = 0 FakeTerminalSession.instances = [] @@ -99,6 +148,23 @@ beforeEach(() => { projectsVisible.value = false document.body.replaceChildren() localStorage.clear() + renderTelemetryGauge.mockClear() + mountPushToggle.mockClear() + mountQuickReply.mockClear() + mountTimeline.mockClear() + timelineDispose.mockClear() + createVoiceInput.mockClear() + voiceStart.mockClear() + voiceStop.mockClear() + quickReply.onSend = undefined + voice.onTranscript = undefined + voice.onInterim = undefined + // Default: server forbids 'auto' (loadUiConfig resolves to allowAutoMode=false). + vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('no fetch in test') })) +}) + +afterEach(() => { + vi.unstubAllGlobals() }) describe('TabApp — v0.5 launcher chooser', () => { @@ -511,3 +577,216 @@ describe('TabApp — Home (⌂) button: reach the chooser from an open session', expect(seg.style.display).toBe('flex') // still shown, no crash }) }) + +describe('TabApp — v0.7 panels mount once (A1 push / A3 quick-reply)', () => { + it('mounts the 🔔 push toggle exactly once on construction (A1)', () => { + const { paneHost, tabBar } = makeHosts() + new TabApp(paneHost, tabBar) + expect(mountPushToggle).toHaveBeenCalledTimes(1) + }) + + it('mounts the quick-reply row directly above #keybar (A3)', () => { + const { paneHost, tabBar } = makeHosts() + new TabApp(paneHost, tabBar) + expect(mountQuickReply).toHaveBeenCalledTimes(1) + const qr = document.getElementById('quickreply') + expect(qr).not.toBeNull() + expect(qr?.nextElementSibling).toBe(document.getElementById('keybar')) + }) + + it('quick-reply onSend routes bytes to the active session (A3-FR3)', () => { + const { paneHost, tabBar } = makeHosts() + const app = new TabApp(paneHost, tabBar) + app.newTab() + quickReply.onSend?.('yes\r') + expect(FakeTerminalSession.instances[0]!.send).toHaveBeenCalledWith('yes\r') + }) + + it('the push bell survives a tab-bar rebuild (re-parented, not re-mounted)', () => { + const { paneHost, tabBar } = makeHosts() + const app = new TabApp(paneHost, tabBar) + app.newTab() // rebuild + app.newTab() // rebuild again + expect(mountPushToggle).toHaveBeenCalledTimes(1) + expect(tabBar.querySelector('.push-host')).not.toBeNull() + }) +}) + +describe('TabApp — B2 telemetry gauge + A5 stuck badge (SP10/M4)', () => { + it('renders the telemetry gauge from the session.telemetry single source of truth', () => { + const { paneHost, tabBar } = makeHosts() + const app = new TabApp(paneHost, tabBar) + app.newTab() + const session = FakeTerminalSession.instances[0]! + const tele = { at: Date.now(), costUsd: 0.5, contextUsedPct: 40 } + session.telemetry = tele + renderTelemetryGauge.mockClear() + session.cbs.onTelemetry?.(tele) // server pushed new telemetry + expect(renderTelemetryGauge).toHaveBeenCalled() + expect(renderTelemetryGauge.mock.calls.at(-1)![1]).toBe(tele) + }) + + it('a stuck claudeStatus shows ⚠ + the claude-stuck class on a background tab (AC-A5.5)', () => { + const { paneHost, tabBar } = makeHosts() + const app = new TabApp(paneHost, tabBar) + app.newTab() // tab 0 + app.newTab() // tab 1 active → tab 0 background + const bg = FakeTerminalSession.instances[0]! + bg.claudeStatus = 'stuck' + bg.cbs.onClaudeStatus?.('stuck') + const tab0 = tabBar.querySelectorAll('.tab')[0]! + expect(tab0.classList.contains('claude-stuck')).toBe(true) + expect(tab0.querySelector('.tab-claude')?.textContent).toBe('⚠') + }) +}) + +describe('TabApp — B4 plan-gate approval bar', () => { + function openWithGate(gate: 'tool' | 'plan', tool?: string): FakeTerminalSession { + const { paneHost, tabBar } = makeHosts() + const app = new TabApp(paneHost, tabBar) + app.newTab() + const session = FakeTerminalSession.instances.at(-1)! + session.pendingApproval = true + session.pendingGate = gate + session.pendingTool = tool + session.cbs.onClaudeStatus?.('waiting') + return session + } + + it('a tool gate renders exactly two buttons (no regression, AC-B4.3)', () => { + openWithGate('tool', 'Bash') + const bar = document.getElementById('approvalbar')! + expect(bar.style.display).toBe('flex') + expect(bar.querySelectorAll('button')).toHaveLength(2) + }) + + it('a plan gate maps three buttons to acceptEdits / default / reject (AC-B4.2)', () => { + const session = openWithGate('plan') + const bar = document.getElementById('approvalbar')! + const click = (i: number): void => (bar.querySelectorAll('button')[i] as HTMLButtonElement).click() + expect(bar.querySelectorAll('button')).toHaveLength(3) + click(0) // Approve + Auto + expect(session.approve).toHaveBeenLastCalledWith('acceptEdits') + click(1) // Approve + Review + expect(session.approve).toHaveBeenLastCalledWith('default') + click(2) // Keep Planning + expect(session.reject).toHaveBeenCalled() + }) +}) + +describe('TabApp — B4 permission-mode relay', () => { + it('openProject with a non-default mode upgrades the launch command (AC-B4.1)', () => { + const { paneHost, tabBar } = makeHosts() + const app = new TabApp(paneHost, tabBar) + app.openProject('/p', 'repo', 'claude\r', 'plan') + expect(FakeTerminalSession.instances.at(-1)!.initialInput).toBe( + 'claude --permission-mode plan\r', + ) + }) + + it('openProject with default mode keeps the plain claude command (no regression)', () => { + const { paneHost, tabBar } = makeHosts() + const app = new TabApp(paneHost, tabBar) + app.openProject('/p', 'repo', 'claude\r', 'default') + expect(FakeTerminalSession.instances.at(-1)!.initialInput).toBe('claude\r') + }) + + it('availablePermissionModes hides "auto" until the server allows it (SEC-M5/AC-B4.4)', () => { + const { paneHost, tabBar } = makeHosts() + const app = new TabApp(paneHost, tabBar) // default fetch stub → allowAutoMode false + expect(app.availablePermissionModes()).toEqual(['default', 'acceptEdits', 'plan']) + }) + + it('availablePermissionModes includes "auto" once /config/ui allows it', async () => { + stubUiConfigFetch(true) + const { paneHost, tabBar } = makeHosts() + const app = new TabApp(paneHost, tabBar) + await vi.waitFor(() => expect(app.availablePermissionModes()).toContain('auto')) + }) + + it('loadDefaultMode downgrades stored "auto" to "default" when the server forbids it', () => { + localStorage.setItem('web-terminal:permission-mode', 'auto') + const { paneHost, tabBar } = makeHosts() + const app = new TabApp(paneHost, tabBar) // allowAutoMode stays false + expect(app.loadDefaultMode()).toBe('default') + }) + + it('saveDefaultMode round-trips a valid mode; invalid stored value falls back', () => { + const { paneHost, tabBar } = makeHosts() + const app = new TabApp(paneHost, tabBar) + app.saveDefaultMode('plan') + expect(app.loadDefaultMode()).toBe('plan') + localStorage.setItem('web-terminal:permission-mode', 'garbage') + expect(app.loadDefaultMode()).toBe('default') + }) +}) + +describe('TabApp — A4 timeline panel', () => { + const SID = '44444444-4444-4444-8444-444444444444' + + it('toggling 📜 mounts the timeline for the active session, then disposes on close', () => { + const { paneHost, tabBar } = makeHosts() + const app = new TabApp(paneHost, tabBar) + app.openSession(SID) + const tlBtn = tabBar.querySelector('.tab-timeline') as HTMLButtonElement + expect(tlBtn).not.toBeNull() + + tlBtn.click() // open + expect(mountTimeline).toHaveBeenCalledTimes(1) + expect(document.getElementById('timeline-panel')!.style.display).toBe('block') + + tlBtn.click() // close + expect(timelineDispose).toHaveBeenCalled() + expect(document.getElementById('timeline-panel')!.style.display).toBe('none') + }) + + it('closing a tab with its timeline open disposes the polling handle', () => { + const { paneHost, tabBar } = makeHosts() + const app = new TabApp(paneHost, tabBar) + app.openSession(SID) + ;(tabBar.querySelector('.tab-timeline') as HTMLButtonElement).click() // open + expect(mountTimeline).toHaveBeenCalled() + app.closeTab(0) + expect(timelineDispose).toHaveBeenCalled() + }) +}) + +describe('TabApp — A2 push-to-talk voice', () => { + it('handleVoiceTrigger("start") creates a recognizer and shows the interim overlay', () => { + const { paneHost, tabBar } = makeHosts() + const app = new TabApp(paneHost, tabBar) + app.handleVoiceTrigger('start') + expect(createVoiceInput).toHaveBeenCalledTimes(1) + expect(voiceStart).toHaveBeenCalled() + expect(document.getElementById('voice-interim')!.style.display).toBe('block') + }) + + it('a final transcript is sent to the active session as raw input (A2-FR1)', () => { + const { paneHost, tabBar } = makeHosts() + const app = new TabApp(paneHost, tabBar) + app.newTab() + app.handleVoiceTrigger('start') + voice.onTranscript?.('list the files') + expect(FakeTerminalSession.instances[0]!.send).toHaveBeenCalledWith('list the files') + }) + + it('interim text updates the overlay; "stop" hides it (A2-FR2)', () => { + const { paneHost, tabBar } = makeHosts() + const app = new TabApp(paneHost, tabBar) + app.handleVoiceTrigger('start') + voice.onInterim?.('hello wor') + const overlay = document.getElementById('voice-interim')! + expect(overlay.textContent).toBe('hello wor') + app.handleVoiceTrigger('stop') + expect(voiceStop).toHaveBeenCalled() + expect(overlay.style.display).toBe('none') + }) + + it('voice is a no-op when SpeechRecognition is unsupported (AC-A2.3)', () => { + createVoiceInput.mockReturnValueOnce(null as never) + const { paneHost, tabBar } = makeHosts() + const app = new TabApp(paneHost, tabBar) + expect(() => app.handleVoiceTrigger('start')).not.toThrow() + expect(voiceStart).not.toHaveBeenCalled() + }) +}) diff --git a/test/telemetry-gauge.test.ts b/test/telemetry-gauge.test.ts new file mode 100644 index 0000000..c8a4e95 --- /dev/null +++ b/test/telemetry-gauge.test.ts @@ -0,0 +1,305 @@ +// @vitest-environment jsdom +/** + * test/telemetry-gauge.test.ts — renderTelemetryGauge + renderStatusBadge + statusText('stuck') + * + * Covers T-preview-grid W1: telemetry gauge, status badge, and statusText extension. + * Security: SEC-H5 (textContent only), SEC-L5 (PR URL https scheme guard). + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { StatusTelemetry, ClaudeStatus } from '../src/types.js' + +// Stub xterm so jsdom does not fail on canvas operations (same as preview-grid.test.ts) +vi.mock('@xterm/xterm', () => ({ + Terminal: class FakeTerm { + cols = 80 + rows = 24 + open = vi.fn() + reset = vi.fn() + resize = vi.fn() + dispose = vi.fn() + write = vi.fn((_d: string, cb?: () => void) => cb?.()) + }, +})) + +const { renderTelemetryGauge, renderStatusBadge, statusText } = await import('../public/preview-grid.js') + +/** Helper: build a StatusTelemetry with only `at` (everything else optional). */ +function makeTelemetry(over: Partial = {}): StatusTelemetry { + return { at: Date.now(), ...over } +} + +function makeContainer(): HTMLDivElement { + return document.createElement('div') +} + +beforeEach(() => { + vi.restoreAllMocks() +}) + +// ─────────────────────── statusText — stuck extension ──────────────────────── + +describe('statusText — stuck extension', () => { + it('returns text containing ⚠ for stuck status', () => { + const text = statusText('stuck' as ClaudeStatus) + expect(text).toContain('⚠') + expect(text).toContain('stuck') + }) + + it('still maps existing statuses correctly (no regression)', () => { + expect(statusText('working')).toContain('working') + expect(statusText('waiting')).toContain('waiting') + expect(statusText('idle')).toContain('idle') + expect(statusText('unknown')).toBe('·') + }) +}) + +// ─────────────────────── renderTelemetryGauge ──────────────────────────────── + +describe('renderTelemetryGauge', () => { + it('renders nothing for null telemetry', () => { + const c = makeContainer() + renderTelemetryGauge(c, null, 30_000) + expect(c.children.length).toBe(0) + }) + + it('is tolerant when telemetry has only the required at field (no optional fields)', () => { + const c = makeContainer() + expect(() => renderTelemetryGauge(c, makeTelemetry(), 30_000)).not.toThrow() + // No optional fields → nothing rendered beyond the stale logic + expect(c.children.length).toBe(0) + }) + + it('clears existing children on each call', () => { + const c = makeContainer() + renderTelemetryGauge(c, makeTelemetry({ model: 'first' }), 30_000) + renderTelemetryGauge(c, makeTelemetry({ model: 'second' }), 30_000) + const chips = c.querySelectorAll('.tg-model') + expect(chips.length).toBe(1) + expect(chips[0]?.textContent).toBe('second') + }) + + // ── context bar ── + + it('renders a context bar when contextUsedPct is present', () => { + const c = makeContainer() + renderTelemetryGauge(c, makeTelemetry({ contextUsedPct: 50 }), 30_000) + expect(c.querySelector('.tg-ctx-bar')).not.toBeNull() + expect(c.querySelector('.tg-ctx-fill')).not.toBeNull() + }) + + it('adds warning class on context fill when contextUsedPct > 80', () => { + const c = makeContainer() + renderTelemetryGauge(c, makeTelemetry({ contextUsedPct: 85 }), 30_000) + expect(c.querySelector('.tg-ctx-fill')?.classList.contains('tg-ctx-warn')).toBe(true) + }) + + it('does NOT add warning class when contextUsedPct === 80', () => { + const c = makeContainer() + renderTelemetryGauge(c, makeTelemetry({ contextUsedPct: 80 }), 30_000) + expect(c.querySelector('.tg-ctx-fill')?.classList.contains('tg-ctx-warn')).toBe(false) + }) + + it('does NOT add warning class when contextUsedPct < 80', () => { + const c = makeContainer() + renderTelemetryGauge(c, makeTelemetry({ contextUsedPct: 60 }), 30_000) + expect(c.querySelector('.tg-ctx-fill')?.classList.contains('tg-ctx-warn')).toBe(false) + }) + + it('skips context bar when contextUsedPct is absent', () => { + const c = makeContainer() + renderTelemetryGauge(c, makeTelemetry({}), 30_000) + expect(c.querySelector('.tg-ctx-bar')).toBeNull() + }) + + // ── cost chip ── + + it('renders cost chip with $ prefix when costUsd is present', () => { + const c = makeContainer() + renderTelemetryGauge(c, makeTelemetry({ costUsd: 0.1234 }), 30_000) + const cost = c.querySelector('.tg-cost') + expect(cost).not.toBeNull() + expect(cost?.textContent).toContain('$') + expect(cost?.textContent).toContain('0.1234') + }) + + it('skips cost chip when costUsd is absent', () => { + const c = makeContainer() + renderTelemetryGauge(c, makeTelemetry(), 30_000) + expect(c.querySelector('.tg-cost')).toBeNull() + }) + + // ── model chip ── + + it('renders model chip when model is present', () => { + const c = makeContainer() + renderTelemetryGauge(c, makeTelemetry({ model: 'claude-3-sonnet' }), 30_000) + expect(c.querySelector('.tg-model')?.textContent).toBe('claude-3-sonnet') + }) + + it('renders ' + renderTelemetryGauge(c, makeTelemetry({ model: xss }), 30_000) + const chip = c.querySelector('.tg-model') + expect(chip?.textContent).toBe(xss) + // No actual ' }) + const row = renderTimelineEvent(ev) + // innerHTML of the row should not produce script elements + const scripts = row.querySelectorAll('script') + expect(scripts.length).toBe(0) + // The raw string should appear as text somewhere in the tree + expect(row.textContent).toContain('') + }) + + it('renders the label text as textContent', () => { + const ev = makeEvent({ label: 'edited 3 files' }) + const row = renderTimelineEvent(ev) + expect(row.textContent).toContain('edited 3 files') + }) + + it('renders the icon for the event class', () => { + const ev = makeEvent({ class: 'stuck' }) + const row = renderTimelineEvent(ev) + expect(row.textContent).toContain('⚠') + }) + + it('renders a time portion in the row', () => { + const ev = makeEvent({ at: new Date('2024-06-15T14:32:00').getTime() }) + const row = renderTimelineEvent(ev) + // Should contain digits forming a time pattern (HH:MM) + expect(row.textContent).toMatch(/\d{1,2}:\d{2}/) + }) + + it('renders HTML-special characters in label as plain text', () => { + const ev = makeEvent({ label: 'bold & "quotes"' }) + const row = renderTimelineEvent(ev) + expect(row.querySelectorAll('b').length).toBe(0) + expect(row.textContent).toContain('bold') + }) + + it('renders ANSI escape sequences in label as plain text', () => { + const ev = makeEvent({ label: '\x1b[31mred\x1b[0m text' }) + const row = renderTimelineEvent(ev) + expect(row.textContent).toContain('\x1b[31mred\x1b[0m text') + }) + + it('renders all five classes without throwing', () => { + const classes: TimelineClass[] = ['tool', 'waiting', 'done', 'stuck', 'user'] + for (const cls of classes) { + expect(() => renderTimelineEvent(makeEvent({ class: cls }))).not.toThrow() + } + }) +}) + +// ─── fetchTimeline ─────────────────────────────────────────────────────────── + +describe('fetchTimeline', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('returns [] on network error', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('net fail'))) + const result = await fetchTimeline('sess-1') + expect(result).toEqual([]) + }) + + it('returns [] when response is not ok', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: false, json: async () => [] }), + ) + const result = await fetchTimeline('sess-1') + expect(result).toEqual([]) + }) + + it('returns [] when response JSON is not an array', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: true, json: async () => ({ events: [] }) }), + ) + const result = await fetchTimeline('sess-1') + expect(result).toEqual([]) + }) + + it('returns [] when json() rejects', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: true, json: async () => { throw new Error('bad json') } }), + ) + const result = await fetchTimeline('sess-1') + expect(result).toEqual([]) + }) + + it('fetches from the correct URL', async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => [] }) + vi.stubGlobal('fetch', mockFetch) + await fetchTimeline('my-session-id') + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('my-session-id')) + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/events')) + }) + + it('normalizes and returns valid events', async () => { + const now = Date.now() + const rawEvents = [ + { at: now, class: 'tool', label: 'ran Bash', toolName: 'Bash' }, + { at: now, class: 'done', label: 'session ended' }, + ] + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: true, json: async () => rawEvents }), + ) + const result = await fetchTimeline('sess-2') + expect(result.length).toBe(2) + expect(result[0]?.class).toBe('tool') + expect(result[1]?.class).toBe('done') + }) + + it('filters out invalid events from the response array', async () => { + const now = Date.now() + const rawEvents = [ + { at: now, class: 'tool', label: 'ok' }, + { at: 'bad', class: 'tool', label: 'ok' }, // invalid at + null, + { at: now, class: 'invalid-class', label: 'ok' }, // invalid class + ] + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: true, json: async () => rawEvents }), + ) + const result = await fetchTimeline('sess-3') + expect(result.length).toBe(1) + expect(result[0]?.label).toBe('ok') + }) +}) + +// ─── mountTimeline ─────────────────────────────────────────────────────────── + +describe('mountTimeline', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.restoreAllMocks() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + function makeContainer(): HTMLDivElement { + return document.createElement('div') + } + + /** + * Flush the microtask queue (Promise chain) without advancing fake timers. + * Needed to let `void poll()` → `fetchTimeline()` → `render()` complete. + * Four ticks cover: poll() entry → fetch call → await response → render(). + */ + async function flushPromises(): Promise { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + } + + it('does an initial fetch on mount', async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => [] }) + vi.stubGlobal('fetch', mockFetch) + const container = makeContainer() + const handle = mountTimeline(container, 'sess-1') + await flushPromises() + expect(mockFetch).toHaveBeenCalled() + handle.dispose() + }) + + it('shows empty state when no events are returned', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => [] })) + const container = makeContainer() + const handle = mountTimeline(container, 'sess-empty') + await flushPromises() + expect(container.textContent).toContain('No activity') + handle.dispose() + }) + + it('renders events newest-first (reverses server order)', async () => { + const now = Date.now() + const events = [ + { at: now - 2000, class: 'tool', label: 'first event' }, + { at: now - 1000, class: 'tool', label: 'second event' }, + { at: now, class: 'done', label: 'done event' }, + ] + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => events })) + const container = makeContainer() + const handle = mountTimeline(container, 'sess-order') + await flushPromises() + const rows = container.querySelectorAll('.tl-row') + // Newest first: 'done event' at index 0, 'first event' at last + expect(rows[0]?.textContent).toContain('done event') + expect(rows[rows.length - 1]?.textContent).toContain('first event') + handle.dispose() + }) + + it('caps rendered events at maxEvents', async () => { + const now = Date.now() + const events = Array.from({ length: 10 }, (_, i) => ({ + at: now + i, + class: 'tool', + label: `event ${i}`, + })) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => events })) + const container = makeContainer() + const handle = mountTimeline(container, 'sess-cap', { maxEvents: 3 }) + await flushPromises() + const rows = container.querySelectorAll('.tl-row') + expect(rows.length).toBe(3) + handle.dispose() + }) + + it('polls at the specified refreshMs interval', async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => [] }) + vi.stubGlobal('fetch', mockFetch) + const container = makeContainer() + const handle = mountTimeline(container, 'sess-poll', { refreshMs: 1000 }) + await flushPromises() + const callsAfterMount = mockFetch.mock.calls.length + + // Advance by refreshMs → fire the interval once + vi.advanceTimersByTime(1000) + await flushPromises() + expect(mockFetch.mock.calls.length).toBeGreaterThan(callsAfterMount) + + handle.dispose() + }) + + it('dispose stops polling (no more fetches after dispose)', async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => [] }) + vi.stubGlobal('fetch', mockFetch) + const container = makeContainer() + const handle = mountTimeline(container, 'sess-dispose', { refreshMs: 500 }) + await flushPromises() + + handle.dispose() + const callsAfterDispose = mockFetch.mock.calls.length + + // Advance time — interval is cleared, no more fetches + vi.advanceTimersByTime(2000) + await flushPromises() + expect(mockFetch.mock.calls.length).toBe(callsAfterDispose) + }) + + it('calling dispose twice does not throw', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => [] })) + const container = makeContainer() + const handle = mountTimeline(container, 'sess-double-dispose') + await flushPromises() + expect(() => { + handle.dispose() + handle.dispose() + }).not.toThrow() + }) + + it('returns a handle with a dispose function', () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => [] })) + const container = makeContainer() + const handle = mountTimeline(container, 'sess-handle') + expect(typeof handle.dispose).toBe('function') + handle.dispose() + }) + + it('uses the label field rendered as textContent (SEC-H6)', async () => { + const now = Date.now() + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => [{ at: now, class: 'tool', label: '' }], + }), + ) + const container = makeContainer() + const handle = mountTimeline(container, 'sess-xss') + await flushPromises() + expect(container.querySelectorAll('script').length).toBe(0) + expect(container.textContent).toContain('') + handle.dispose() + }) + + it('clears and re-renders on each poll cycle', async () => { + let callCount = 0 + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(async () => { + callCount++ + const label = `event-cycle-${callCount}` + return { ok: true, json: async () => [{ at: Date.now(), class: 'tool', label }] } + }), + ) + const container = makeContainer() + const handle = mountTimeline(container, 'sess-refresh', { refreshMs: 500 }) + await flushPromises() + expect(container.textContent).toContain('event-cycle-1') + + vi.advanceTimersByTime(500) + await flushPromises() + // After re-render, shows latest cycle data + expect(container.textContent).toContain('event-cycle-2') + // Only shows one set of rows (cleared before re-render) + expect(container.querySelectorAll('.tl-row').length).toBe(1) + + handle.dispose() + }) +}) diff --git a/test/voice.test.ts b/test/voice.test.ts new file mode 100644 index 0000000..30c92bc --- /dev/null +++ b/test/voice.test.ts @@ -0,0 +1,447 @@ +// @vitest-environment jsdom +/** + * test/voice.test.ts — Unit tests for voice.ts (N-voice, A2). + * + * Tests the Web Speech API wrapper (isSpeechSupported / createVoiceInput) + * and the keybar mic-button integration (mountKeybar opts.onVoiceTrigger). + * Runs under jsdom so window / document / TouchEvent are available; the real + * SpeechRecognition is replaced by a lightweight self-registering mock class. + * + * AC-A2.1 final transcript calls onTranscript + * AC-A2.2 touch preventDefault keeps soft keyboard hidden + * AC-A2.3 unsupported → hidden, no crash + * AC-A2.4 autoSend appends \r; off → no \r + * AC-A2.5 audio never reaches server (pure-FE — no server stub needed here) + * SEC-L2 disclosure via title attribute on mic button + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { isSpeechSupported, createVoiceInput } from '../public/voice.js' +import { mountKeybar } from '../public/keybar.js' + +// ── Mock SpeechRecognition ──────────────────────────────────────────────────── +// Using a self-registering class pattern: each `new MockRecognition()` stores +// itself in MockRecognition.last, so tests can reference the instance created +// by createVoiceInput() without needing vi.fn() as the constructor wrapper. + +/** Minimal SpeechRecognitionEvent-like shape for test usage. */ +interface MockResultEvent { + resultIndex: number + results: { + length: number + [index: number]: { + isFinal: boolean + length: number + [index: number]: { transcript: string; confidence: number } + } + } +} + +class MockRecognition { + /** The most-recently constructed instance — set in the constructor. */ + static last: MockRecognition | null = null + + continuous = false + interimResults = false + lang = '' + onresult: ((ev: MockResultEvent) => void) | null = null + onend: (() => void) | null = null + onerror: ((ev: { error: string }) => void) | null = null + + // Arrow-function class fields create fresh vi.fn() per instance + readonly start = vi.fn() + readonly stop = vi.fn() + readonly abort = vi.fn() + + constructor() { + MockRecognition.last = this + } +} + +/** Build a single-result event for testing. */ +function makeResultEvent(transcript: string, isFinal: boolean, resultIndex = 0): MockResultEvent { + const alt = { transcript, confidence: 1.0 } + const result = Object.assign([alt], { isFinal, length: 1 }) + const results = Object.assign([result], { length: 1 }) + return { resultIndex, results } as unknown as MockResultEvent +} + +// ───────────────────────────────────────────────────────────────────────────── +// isSpeechSupported +// ───────────────────────────────────────────────────────────────────────────── + +describe('isSpeechSupported', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('returns false when SpeechRecognition is not in window', () => { + // jsdom does not provide SpeechRecognition by default + expect(isSpeechSupported()).toBe(false) + }) + + it('returns true when SpeechRecognition is present in window', () => { + vi.stubGlobal('SpeechRecognition', MockRecognition) + expect(isSpeechSupported()).toBe(true) + }) + + it('returns true when webkitSpeechRecognition is present in window', () => { + vi.stubGlobal('webkitSpeechRecognition', MockRecognition) + expect(isSpeechSupported()).toBe(true) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// createVoiceInput +// ───────────────────────────────────────────────────────────────────────────── + +describe('createVoiceInput', () => { + beforeEach(() => { + MockRecognition.last = null + vi.stubGlobal('SpeechRecognition', MockRecognition) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + // --- null when unsupported --- + + it('returns null when speech is not supported', () => { + vi.unstubAllGlobals() + const voice = createVoiceInput(vi.fn()) + expect(voice).toBeNull() + }) + + // --- object shape --- + + it('returns a VoiceInput object with required methods', () => { + const voice = createVoiceInput(vi.fn()) + expect(voice).not.toBeNull() + expect(typeof voice?.start).toBe('function') + expect(typeof voice?.stop).toBe('function') + expect(typeof voice?.dispose).toBe('function') + expect(typeof voice?.isActive).toBe('function') + }) + + // --- isActive --- + + it('starts in inactive state', () => { + const voice = createVoiceInput(vi.fn()) + expect(voice?.isActive()).toBe(false) + }) + + it('isActive returns true after start()', () => { + const voice = createVoiceInput(vi.fn()) + voice?.start() + expect(voice?.isActive()).toBe(true) + }) + + it('isActive returns false after stop()', () => { + const voice = createVoiceInput(vi.fn()) + voice?.start() + voice?.stop() + expect(voice?.isActive()).toBe(false) + }) + + it('isActive returns false after natural onend fires', () => { + const voice = createVoiceInput(vi.fn()) + const mock = MockRecognition.last! + voice?.start() + mock.onend?.() + expect(voice?.isActive()).toBe(false) + }) + + it('isActive returns false after onerror fires', () => { + const voice = createVoiceInput(vi.fn()) + const mock = MockRecognition.last! + voice?.start() + mock.onerror?.({ error: 'network' }) + expect(voice?.isActive()).toBe(false) + }) + + // --- recognition.start / stop calls --- + + it('calls recognition.start() on start()', () => { + const voice = createVoiceInput(vi.fn()) + const mock = MockRecognition.last! + voice?.start() + expect(mock.start).toHaveBeenCalledTimes(1) + }) + + it('does not call recognition.start() twice when already active', () => { + const voice = createVoiceInput(vi.fn()) + const mock = MockRecognition.last! + voice?.start() + voice?.start() + expect(mock.start).toHaveBeenCalledTimes(1) + }) + + it('calls recognition.stop() on stop() when active', () => { + const voice = createVoiceInput(vi.fn()) + const mock = MockRecognition.last! + voice?.start() + voice?.stop() + expect(mock.stop).toHaveBeenCalledTimes(1) + }) + + it('does not call recognition.stop() when not active', () => { + const voice = createVoiceInput(vi.fn()) + const mock = MockRecognition.last! + voice?.stop() + expect(mock.stop).not.toHaveBeenCalled() + }) + + // --- transcript callbacks --- + + it('calls onTranscript with final transcript text (AC-A2.1)', () => { + const onTranscript = vi.fn() + const voice = createVoiceInput(onTranscript) + const mock = MockRecognition.last! + voice?.start() + mock.onresult?.(makeResultEvent('hello world', true)) + expect(onTranscript).toHaveBeenCalledWith('hello world') + }) + + it('autoSend=true appends \\r to the transcript (AC-A2.4)', () => { + const onTranscript = vi.fn() + const voice = createVoiceInput(onTranscript, { autoSend: true }) + const mock = MockRecognition.last! + voice?.start() + mock.onresult?.(makeResultEvent('hello', true)) + expect(onTranscript).toHaveBeenCalledWith('hello\r') + }) + + it('autoSend=false does not append \\r (AC-A2.4)', () => { + const onTranscript = vi.fn() + const voice = createVoiceInput(onTranscript, { autoSend: false }) + const mock = MockRecognition.last! + voice?.start() + mock.onresult?.(makeResultEvent('hello', true)) + expect(onTranscript).toHaveBeenCalledWith('hello') + }) + + it('omitting autoSend does not append \\r', () => { + const onTranscript = vi.fn() + const voice = createVoiceInput(onTranscript) + const mock = MockRecognition.last! + voice?.start() + mock.onresult?.(makeResultEvent('hello', true)) + expect(onTranscript).toHaveBeenCalledWith('hello') + }) + + it('calls onInterim with interim transcript text', () => { + const onInterim = vi.fn() + const onTranscript = vi.fn() + const voice = createVoiceInput(onTranscript, { onInterim }) + const mock = MockRecognition.last! + voice?.start() + mock.onresult?.(makeResultEvent('hel', false)) + expect(onInterim).toHaveBeenCalledWith('hel') + expect(onTranscript).not.toHaveBeenCalled() + }) + + it('does not crash when interim fires but onInterim is not provided', () => { + const onTranscript = vi.fn() + const voice = createVoiceInput(onTranscript) + const mock = MockRecognition.last! + voice?.start() + expect(() => mock.onresult?.(makeResultEvent('par', false))).not.toThrow() + expect(onTranscript).not.toHaveBeenCalled() + }) + + it('does not call onTranscript for interim-only results', () => { + const onTranscript = vi.fn() + const voice = createVoiceInput(onTranscript, { onInterim: vi.fn() }) + const mock = MockRecognition.last! + voice?.start() + mock.onresult?.(makeResultEvent('partial', false)) + expect(onTranscript).not.toHaveBeenCalled() + }) + + // --- dispose --- + + it('does not fire onTranscript after dispose()', () => { + const onTranscript = vi.fn() + const voice = createVoiceInput(onTranscript) + const mock = MockRecognition.last! + voice?.start() + voice?.dispose() + mock.onresult?.(makeResultEvent('late transcript', true)) + expect(onTranscript).not.toHaveBeenCalled() + }) + + it('calls recognition.abort() when disposed while active', () => { + const voice = createVoiceInput(vi.fn()) + const mock = MockRecognition.last! + voice?.start() + voice?.dispose() + expect(mock.abort).toHaveBeenCalledTimes(1) + }) + + it('does not call recognition.abort() when disposed while inactive', () => { + const voice = createVoiceInput(vi.fn()) + const mock = MockRecognition.last! + voice?.dispose() + expect(mock.abort).not.toHaveBeenCalled() + }) + + it('dispose() is idempotent — double dispose is safe', () => { + const voice = createVoiceInput(vi.fn()) + const mock = MockRecognition.last! + voice?.start() + expect(() => { voice?.dispose(); voice?.dispose() }).not.toThrow() + expect(mock.abort).toHaveBeenCalledTimes(1) + }) + + it('start() after dispose() is a no-op', () => { + const voice = createVoiceInput(vi.fn()) + const mock = MockRecognition.last! + voice?.dispose() + voice?.start() + expect(mock.start).not.toHaveBeenCalled() + }) + + // --- configuration --- + + it('sets recognition.lang to the provided lang option', () => { + createVoiceInput(vi.fn(), { lang: 'ja-JP' }) + expect(MockRecognition.last?.lang).toBe('ja-JP') + }) + + it('uses navigator.language by default', () => { + createVoiceInput(vi.fn()) + expect(MockRecognition.last?.lang).toBe(navigator.language) + }) + + it('sets interimResults=true when onInterim is provided', () => { + createVoiceInput(vi.fn(), { onInterim: vi.fn() }) + expect(MockRecognition.last?.interimResults).toBe(true) + }) + + it('sets interimResults=false when onInterim is not provided', () => { + createVoiceInput(vi.fn()) + expect(MockRecognition.last?.interimResults).toBe(false) + }) + + it('uses webkitSpeechRecognition when SpeechRecognition is absent', () => { + vi.unstubAllGlobals() + // Re-create MockRecognition.last tracking + class WebkitMockRecognition extends MockRecognition {} + vi.stubGlobal('webkitSpeechRecognition', WebkitMockRecognition) + + const onTranscript = vi.fn() + const voice = createVoiceInput(onTranscript) + expect(voice).not.toBeNull() + voice?.start() + expect(MockRecognition.last?.start).toHaveBeenCalled() + vi.unstubAllGlobals() + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// mountKeybar voice button (onVoiceTrigger integration) +// ───────────────────────────────────────────────────────────────────────────── + +describe('mountKeybar voice button', () => { + beforeEach(() => { + document.body.innerHTML = '
' + }) + + afterEach(() => { + document.body.innerHTML = '' + vi.unstubAllGlobals() + }) + + it('does not append a mic button when speech is not supported (AC-A2.3)', () => { + // No SpeechRecognition in window by default in jsdom + mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() }) + expect(document.querySelector('[data-key="voice"]')).toBeNull() + }) + + it('does not append a mic button when onVoiceTrigger is not provided', () => { + vi.stubGlobal('SpeechRecognition', MockRecognition) + mountKeybar(vi.fn()) + expect(document.querySelector('[data-key="voice"]')).toBeNull() + }) + + it('appends a mic button when supported and onVoiceTrigger is provided', () => { + vi.stubGlobal('SpeechRecognition', MockRecognition) + mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() }) + expect(document.querySelector('[data-key="voice"]')).not.toBeNull() + }) + + it('mic button displays 🎤 key label', () => { + vi.stubGlobal('SpeechRecognition', MockRecognition) + mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() }) + const keyEl = document.querySelector('[data-key="voice"] .kb-key') + expect(keyEl?.textContent).toBe('🎤') + }) + + it('mic button has a caption label', () => { + vi.stubGlobal('SpeechRecognition', MockRecognition) + mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() }) + const capEl = document.querySelector('[data-key="voice"] .kb-cap') + expect(capEl?.textContent).toBeTruthy() + }) + + it('touchstart calls onVoiceTrigger("start") and prevents default (AC-A2.2)', () => { + vi.stubGlobal('SpeechRecognition', MockRecognition) + const onVoiceTrigger = vi.fn() + mountKeybar(vi.fn(), { onVoiceTrigger }) + const btn = document.querySelector('[data-key="voice"]')! + const ev = new Event('touchstart', { cancelable: true, bubbles: true }) + btn.dispatchEvent(ev) + expect(onVoiceTrigger).toHaveBeenCalledWith('start') + }) + + it('touchend calls onVoiceTrigger("stop") and prevents default', () => { + vi.stubGlobal('SpeechRecognition', MockRecognition) + const onVoiceTrigger = vi.fn() + mountKeybar(vi.fn(), { onVoiceTrigger }) + const btn = document.querySelector('[data-key="voice"]')! + const ev = new Event('touchend', { cancelable: true, bubbles: true }) + btn.dispatchEvent(ev) + expect(onVoiceTrigger).toHaveBeenCalledWith('stop') + }) + + it('mousedown calls onVoiceTrigger("start") for desktop fallback', () => { + vi.stubGlobal('SpeechRecognition', MockRecognition) + const onVoiceTrigger = vi.fn() + mountKeybar(vi.fn(), { onVoiceTrigger }) + const btn = document.querySelector('[data-key="voice"]')! + btn.dispatchEvent(new MouseEvent('mousedown', { cancelable: true, bubbles: true })) + expect(onVoiceTrigger).toHaveBeenCalledWith('start') + }) + + it('mouseup calls onVoiceTrigger("stop") for desktop fallback', () => { + vi.stubGlobal('SpeechRecognition', MockRecognition) + const onVoiceTrigger = vi.fn() + mountKeybar(vi.fn(), { onVoiceTrigger }) + const btn = document.querySelector('[data-key="voice"]')! + btn.dispatchEvent(new MouseEvent('mouseup', { cancelable: true, bubbles: true })) + expect(onVoiceTrigger).toHaveBeenCalledWith('stop') + }) + + it('mic button title discloses audio privacy (SEC-L2)', () => { + vi.stubGlobal('SpeechRecognition', MockRecognition) + mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() }) + const btn = document.querySelector('[data-key="voice"]')! + expect(btn.title.toLowerCase()).toMatch(/audio/) + }) + + it('existing key buttons are still rendered when voice opts provided', () => { + vi.stubGlobal('SpeechRecognition', MockRecognition) + mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() }) + expect(document.querySelector('[data-key="esc"]')).not.toBeNull() + }) + + it('mountKeybar works with no opts (backward compat)', () => { + expect(() => mountKeybar(vi.fn())).not.toThrow() + }) + + it('mountKeybar returns without error when #keybar element is absent', () => { + document.body.innerHTML = '' // no #keybar + expect(() => mountKeybar(vi.fn(), { onVoiceTrigger: vi.fn() })).not.toThrow() + }) +}) diff --git a/test/worktree-form.test.ts b/test/worktree-form.test.ts new file mode 100644 index 0000000..bf79eee --- /dev/null +++ b/test/worktree-form.test.ts @@ -0,0 +1,551 @@ +// @vitest-environment jsdom +/** + * test/worktree-form.test.ts — T-projects-ui (v0.7 Walk-away Workbench) + * + * Tests for the new features wired into public/projects.ts: + * B1: "View Diff" toggle button → mountDiffViewer inline panel + * B3: renderNewWorktreeForm — client-side branch validation + POST /projects/worktree + * A4: "Activity" section — mountTimeline per running session, dispose on onBack + * + * Security: SEC-H4 (diff textContent), SEC-L3/H6 (error/label textContent) + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { ProjectDetail } from '../src/types.js' + +// ── Stub @xterm/xterm (transitively via preview-grid.ts) ────────────────────── +class FakeTerminal { + open = vi.fn() + dispose = vi.fn() +} +vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal })) + +// ── Stub diff viewer ─────────────────────────────────────────────────────────── +const mockDiffHandle = { + showWorking: vi.fn(), + showStaged: vi.fn(), + close: vi.fn(), + destroy: vi.fn(), +} +const mockMountDiffViewer = vi.fn(() => mockDiffHandle) +vi.mock('../public/diff.js', () => ({ mountDiffViewer: mockMountDiffViewer })) + +// ── Stub timeline ────────────────────────────────────────────────────────────── +const mockTimelineHandle = { dispose: vi.fn() } +const mockMountTimeline = vi.fn(() => mockTimelineHandle) +vi.mock('../public/timeline.js', () => ({ mountTimeline: mockMountTimeline })) + +// ── Import AFTER mocks ───────────────────────────────────────────────────────── +const { validateBranchNameClient, renderNewWorktreeForm, renderProjectDetail } = + await import('../public/projects.js') + +/* ── Helpers ───────────────────────────────────────────────────────────────── */ + +function makeHooks() { + return { onOpenProject: vi.fn(), onEnterSession: vi.fn() } +} + +function makeCbs() { + return { onBack: vi.fn(), onKill: vi.fn() } +} + +function makeDetail(overrides: Partial = {}): ProjectDetail { + return { + name: 'my-repo', + path: '/home/user/my-repo', + isGit: true, + branch: 'main', + worktrees: [], + sessions: [], + hasClaudeMd: false, + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', vi.fn()) +}) + +/* ── validateBranchNameClient ──────────────────────────────────────────────── */ + +describe('validateBranchNameClient', () => { + it('returns null for a valid simple branch name', () => { + expect(validateBranchNameClient('my-branch')).toBeNull() + expect(validateBranchNameClient('feat-123')).toBeNull() + expect(validateBranchNameClient('v0.7')).toBeNull() + }) + + it('returns null for namespaced branches (feature/foo)', () => { + expect(validateBranchNameClient('feature/my-feature')).toBeNull() + expect(validateBranchNameClient('v0.7/walk-away')).toBeNull() + expect(validateBranchNameClient('user/fix/bug-1')).toBeNull() + }) + + it('returns an error string for an empty string', () => { + const result = validateBranchNameClient('') + expect(typeof result).toBe('string') + expect(result).not.toBeNull() + }) + + it('returns an error for a name longer than 250 characters', () => { + expect(validateBranchNameClient('a'.repeat(251))).not.toBeNull() + }) + + it('returns null for a name that is exactly 250 characters', () => { + expect(validateBranchNameClient('a'.repeat(250))).toBeNull() + }) + + it('returns an error for a name starting with "-"', () => { + expect(validateBranchNameClient('-bad')).not.toBeNull() + }) + + it('returns an error for a name containing ".."', () => { + expect(validateBranchNameClient('feat..bar')).not.toBeNull() + expect(validateBranchNameClient('..feat')).not.toBeNull() + }) + + it('returns an error for a name ending with ".lock"', () => { + expect(validateBranchNameClient('main.lock')).not.toBeNull() + }) + + it('returns an error for a name with control characters', () => { + expect(validateBranchNameClient('feat\x01bar')).not.toBeNull() + expect(validateBranchNameClient('feat\x00bar')).not.toBeNull() + }) + + it('returns an error for a name with tab', () => { + expect(validateBranchNameClient('feat\tbar')).not.toBeNull() + }) + + it('returns an error for a name with spaces', () => { + expect(validateBranchNameClient('my branch')).not.toBeNull() + }) + + it('returns an error for a name with "~"', () => { + expect(validateBranchNameClient('feat~1')).not.toBeNull() + }) + + it('returns an error for a name with "^"', () => { + expect(validateBranchNameClient('feat^bar')).not.toBeNull() + }) + + it('returns an error for a name with ":"', () => { + expect(validateBranchNameClient('feat:bar')).not.toBeNull() + }) + + it('returns an error for a name with "?"', () => { + expect(validateBranchNameClient('feat?bar')).not.toBeNull() + }) + + it('returns an error for a name with "*"', () => { + expect(validateBranchNameClient('feat*bar')).not.toBeNull() + }) + + it('returns an error for a name with "["', () => { + expect(validateBranchNameClient('feat[bar')).not.toBeNull() + }) + + it('returns an error for a name with backslash', () => { + expect(validateBranchNameClient('feat\\bar')).not.toBeNull() + }) + + it('returns an error for a name with "@{"', () => { + expect(validateBranchNameClient('feat@{bar}')).not.toBeNull() + expect(validateBranchNameClient('@{bar}')).not.toBeNull() + }) + + it('returns an error for a name starting with "/"', () => { + expect(validateBranchNameClient('/feat')).not.toBeNull() + }) + + it('returns an error for a name ending with "/"', () => { + expect(validateBranchNameClient('feat/')).not.toBeNull() + }) + + it('returns an error for a name with "//"', () => { + expect(validateBranchNameClient('feat//bar')).not.toBeNull() + }) + + it('all invalid cases return a non-null string', () => { + const invalids = [ + '', + '-bad', + 'feat..bar', + 'main.lock', + 'feat\x01', + 'feat\tbar', + 'feat bar', + 'feat~1', + 'feat^x', + 'feat:x', + 'feat?x', + 'feat*x', + 'feat[x', + 'feat\\x', + 'feat@{x}', + '/bad', + 'bad/', + 'feat//bar', + ] + for (const b of invalids) { + const result = validateBranchNameClient(b) + expect(typeof result).toBe('string') + } + }) +}) + +/* ── renderNewWorktreeForm ─────────────────────────────────────────────────── */ + +describe('renderNewWorktreeForm', () => { + it('renders a form container with a branch input and submit button', () => { + const form = renderNewWorktreeForm(makeDetail(), makeHooks()) + expect(form.querySelector('input')).not.toBeNull() + expect(form.querySelector('button')).not.toBeNull() + // Error element exists but is hidden initially + const errorEl = form.querySelector('.proj-wt-error') as HTMLElement | null + expect(errorEl).not.toBeNull() + expect(errorEl!.style.display).toBe('none') + }) + + it('shows a validation error when submitted with an empty branch name', () => { + const hooks = makeHooks() + const form = renderNewWorktreeForm(makeDetail(), hooks) + ;(form.querySelector('button') as HTMLButtonElement).click() + + expect(fetch).not.toHaveBeenCalled() + const errorEl = form.querySelector('.proj-wt-error') as HTMLElement + expect(errorEl.style.display).not.toBe('none') + expect(errorEl.textContent).toBeTruthy() + }) + + it('shows a validation error for an invalid branch name (leading hyphen)', () => { + const hooks = makeHooks() + const form = renderNewWorktreeForm(makeDetail(), hooks) + ;(form.querySelector('input') as HTMLInputElement).value = '-bad' + ;(form.querySelector('button') as HTMLButtonElement).click() + + expect(fetch).not.toHaveBeenCalled() + const errorEl = form.querySelector('.proj-wt-error') as HTMLElement + expect(errorEl.style.display).not.toBe('none') + }) + + it('POSTs to /projects/worktree with repoPath and branch on valid input', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ok: true, path: '/home/user/my-repo-worktrees/feat', branch: 'feat' }), + }) + vi.stubGlobal('fetch', mockFetch) + + const detail = makeDetail({ path: '/home/user/my-repo' }) + const form = renderNewWorktreeForm(detail, makeHooks()) + ;(form.querySelector('input') as HTMLInputElement).value = 'feat' + ;(form.querySelector('button') as HTMLButtonElement).click() + + await Promise.resolve() + await Promise.resolve() + + expect(mockFetch).toHaveBeenCalledWith( + '/projects/worktree', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ repoPath: '/home/user/my-repo', branch: 'feat' }), + }), + ) + }) + + it('calls hooks.onOpenProject with the worktree path and branch on success', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + ok: true, + path: '/home/user/my-repo-worktrees/feat', + branch: 'feat', + }), + }) + vi.stubGlobal('fetch', mockFetch) + + const hooks = makeHooks() + const form = renderNewWorktreeForm(makeDetail(), hooks) + ;(form.querySelector('input') as HTMLInputElement).value = 'feat' + ;(form.querySelector('button') as HTMLButtonElement).click() + + // Flush async microtasks + await Promise.resolve() + await Promise.resolve() + + expect(hooks.onOpenProject).toHaveBeenCalledWith( + '/home/user/my-repo-worktrees/feat', + 'feat', + 'claude\r', + ) + }) + + it('falls back to repoPath/branch when response omits path/branch', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ok: true }), + }) + vi.stubGlobal('fetch', mockFetch) + + const hooks = makeHooks() + const detail = makeDetail({ path: '/repo' }) + const form = renderNewWorktreeForm(detail, hooks) + ;(form.querySelector('input') as HTMLInputElement).value = 'feat' + ;(form.querySelector('button') as HTMLButtonElement).click() + + await Promise.resolve() + await Promise.resolve() + + expect(hooks.onOpenProject).toHaveBeenCalledWith('/repo', 'feat', 'claude\r') + }) + + it('shows server error message via textContent on HTTP failure', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({ ok: false, error: 'Branch already exists' }), + }) + vi.stubGlobal('fetch', mockFetch) + + const hooks = makeHooks() + const form = renderNewWorktreeForm(makeDetail(), hooks) + ;(form.querySelector('input') as HTMLInputElement).value = 'feat' + ;(form.querySelector('button') as HTMLButtonElement).click() + + await Promise.resolve() + await Promise.resolve() + + const errorEl = form.querySelector('.proj-wt-error') as HTMLElement + expect(errorEl.style.display).not.toBe('none') + expect(errorEl.textContent).toBe('Branch already exists') + expect(hooks.onOpenProject).not.toHaveBeenCalled() + }) + + it('shows a generic error on network failure (never throws)', async () => { + const mockFetch = vi.fn().mockRejectedValue(new Error('Network error')) + vi.stubGlobal('fetch', mockFetch) + + const hooks = makeHooks() + const form = renderNewWorktreeForm(makeDetail(), hooks) + ;(form.querySelector('input') as HTMLInputElement).value = 'feat' + ;(form.querySelector('button') as HTMLButtonElement).click() + + await Promise.resolve() + await Promise.resolve() + + const errorEl = form.querySelector('.proj-wt-error') as HTMLElement + expect(errorEl.textContent).toBeTruthy() + expect(hooks.onOpenProject).not.toHaveBeenCalled() + }) + + it('SEC-H6: error message set via textContent — ' + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({ ok: false, error: xss }), + }) + vi.stubGlobal('fetch', mockFetch) + + const form = renderNewWorktreeForm(makeDetail(), makeHooks()) + ;(form.querySelector('input') as HTMLInputElement).value = 'feat' + ;(form.querySelector('button') as HTMLButtonElement).click() + + await Promise.resolve() + await Promise.resolve() + + const errorEl = form.querySelector('.proj-wt-error') as HTMLElement + // No