feat(v0.7): Walk-away Workbench (Band A + B) — multi-agent parallel build

Implements docs/PLAN_WALKAWAY_WORKBENCH.md (27 tasks, waves R0→W0→W1×14→W2→W3→W4)
via module-builder agents. 23 tasks built, 0 blocked.

Band A (finish the walk-away loop): A1 Web Push + lock-screen approve/deny
(web-push dep), A2 voice dictation, A3 quick-reply chips + saved-prompt palette,
A4 activity timeline, A5 stuck/idle alert.
Band B (workbench above the terminal): B1 read-only git diff viewer, B2 statusLine
telemetry → per-tab cost/context/PR gauges, B3 create git worktrees from the UI,
B4 plan-mode / permission-mode relay.

New: src/push/* (subscription store + VAPID push), src/http/{diff,statusline}.ts,
src/session/timeline.ts, public/{diff,timeline,quickreply,push-ui,...}.ts, sw-push,
statusLine script; extends hook intake, manager, server routes (Origin/CSRF guards
+ per-IP rate limits on state-changing ones; loopback-only ingest), terminal-session,
tabs, projects detail, service worker, setup-hooks (statusLine + ntfy bridge).

Orchestrator reconciled a W0 contract gap: added the 21 v0.7 Config fields to the
Config interface in types.ts (T-types had left them only in config.ts's return).

Verified: both tsc clean, full vitest + coverage 91.4/84.1/92.2/93.4 (≥80×4),
build:web OK. W4 review: no CRITICAL/HIGH; all security checks pass. Follow-ups
(non-blocking): move approve.mode validation into parseClientMessage, drop CSP
ws:/wss: wildcard, validate worktree base ref, +2 targeted tests.
This commit is contained in:
Yaojia Wang
2026-06-30 17:42:18 +02:00
parent 4f1d3ebc6b
commit d6809c65c4
54 changed files with 13171 additions and 200 deletions

View File

@@ -146,6 +146,15 @@
- **commit**: <hash 或 N/A> - **commit**: <hash 或 N/A>
======================================================= --> ======================================================= -->
### 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` 续跑 W1W4。
- **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 ### 2026-06-30 · v0.6 工具栏图标换成线性图标 + hover tooltip
- **状态**: `[x]` DONE。web typecheck 干净 · `build:web` OK · 真浏览器验证通过。 - **状态**: `[x]` DONE。web typecheck 干净 · `build:web` OK · 真浏览器验证通过。

151
package-lock.json generated
View File

@@ -16,12 +16,14 @@
"express": "^5.2.1", "express": "^5.2.1",
"node-pty": "^1.1.0", "node-pty": "^1.1.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"web-push": "3.6.7",
"ws": "^8.21.0" "ws": "^8.21.0"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/node": "^25.9.3", "@types/node": "^25.9.3",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/web-push": "3.6.4",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^4.1.9", "@vitest/coverage-v8": "^4.1.9",
"esbuild": "^0.28.1", "esbuild": "^0.28.1",
@@ -1264,6 +1266,16 @@
"@types/node": "*" "@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": { "node_modules/@types/ws": {
"version": "8.18.1", "version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -1458,6 +1470,15 @@
"node": ">= 0.6" "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": { "node_modules/ansi-regex": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "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" "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": { "node_modules/assertion-error": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -1514,6 +1547,12 @@
"require-from-string": "^2.0.2" "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": { "node_modules/body-parser": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
@@ -1551,6 +1590,12 @@
"url": "https://opencollective.com/express" "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": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1784,6 +1829,15 @@
"node": ">= 0.4" "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": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -2181,6 +2235,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -2201,6 +2264,19 @@
"url": "https://opencollective.com/express" "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": { "node_modules/iconv-lite": {
"version": "0.7.2", "version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", "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": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -2724,6 +2821,21 @@
"url": "https://opencollective.com/express" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -3117,6 +3229,26 @@
"node": ">= 18" "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": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -3751,6 +3883,25 @@
"node": ">=18" "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": { "node_modules/webidl-conversions": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",

View File

@@ -27,12 +27,14 @@
"express": "^5.2.1", "express": "^5.2.1",
"node-pty": "^1.1.0", "node-pty": "^1.1.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"web-push": "3.6.7",
"ws": "^8.21.0" "ws": "^8.21.0"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/node": "^25.9.3", "@types/node": "^25.9.3",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/web-push": "3.6.4",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^4.1.9", "@vitest/coverage-v8": "^4.1.9",
"esbuild": "^0.28.1", "esbuild": "^0.28.1",

347
public/diff.ts Normal file
View File

@@ -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. <script>, & and ANSI escape sequences appear as plain text.
*/
import type { DiffResult, DiffFile, DiffHunk, DiffLine } from '../src/types.js'
/* ── constants ───────────────────────────────────────────────────────────────── */
const EMPTY_MESSAGE = 'No changes'
/* ── DOM helper ──────────────────────────────────────────────────────────────── */
/** Create an element with an optional CSS class and text content. */
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
cls?: string,
text?: string,
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag)
if (cls) node.className = cls
if (text !== undefined) node.textContent = text
return node
}
/* ── normalizeDiffResult ─────────────────────────────────────────────────────── */
/**
* Coerce an untrusted API response into a DiffResult, or return null.
* Never throws. Filters out any file entries that are not valid objects.
*/
export function normalizeDiffResult(raw: unknown): DiffResult | null {
if (raw === null || typeof raw !== 'object') return null
const o = raw as Record<string, unknown>
if (!Array.isArray(o['files'])) return null
if (typeof o['staged'] !== 'boolean') return null
if (typeof o['truncated'] !== 'boolean') return null
const files = (o['files'] as unknown[])
.map(normalizeFile)
.filter((f): f is DiffFile => f !== null)
return { files, staged: o['staged'], truncated: o['truncated'] }
}
function normalizeFile(raw: unknown): DiffFile | null {
if (raw === null || typeof raw !== 'object') return null
const o = raw as Record<string, unknown>
if (typeof o['oldPath'] !== 'string') return null
if (typeof o['newPath'] !== 'string') return null
if (typeof o['status'] !== 'string') return null
if (typeof o['added'] !== 'number') return null
if (typeof o['removed'] !== 'number') return null
if (typeof o['binary'] !== 'boolean') return null
if (!Array.isArray(o['hunks'])) return null
const hunks = (o['hunks'] as unknown[])
.map(normalizeHunk)
.filter((h): h is DiffHunk => h !== null)
return {
oldPath: o['oldPath'],
newPath: o['newPath'],
status: o['status'] as DiffFile['status'],
added: o['added'],
removed: o['removed'],
binary: o['binary'],
hunks,
}
}
function normalizeHunk(raw: unknown): DiffHunk | null {
if (raw === null || typeof raw !== 'object') return null
const o = raw as Record<string, unknown>
if (typeof o['header'] !== 'string') return null
if (!Array.isArray(o['lines'])) return null
const lines = (o['lines'] as unknown[])
.map(normalizeLine)
.filter((l): l is DiffLine => l !== null)
return { header: o['header'], lines }
}
function normalizeLine(raw: unknown): DiffLine | null {
if (raw === null || typeof raw !== 'object') return null
const o = raw as Record<string, unknown>
if (typeof o['kind'] !== 'string') return null
if (typeof o['text'] !== 'string') return null
return { kind: o['kind'] as DiffLine['kind'], text: o['text'] }
}
/* ── fetchDiff ───────────────────────────────────────────────────────────────── */
/**
* Fetch a diff from the server for the given repo path.
* Returns null on any error or invalid response (best-effort).
*/
export async function fetchDiff(repoPath: string, staged: boolean): Promise<DiffResult | null> {
try {
const url = `/projects/diff?path=${encodeURIComponent(repoPath)}&staged=${staged}`
const res = await fetch(url)
if (!res.ok) return null
const data: unknown = await res.json()
return normalizeDiffResult(data)
} catch {
return null
}
}
/* ── renderDiffFile ──────────────────────────────────────────────────────────── */
/** CSS class prefix for diff line kinds. */
const LINE_KIND_CLASS: Record<DiffLine['kind'], string> = {
added: 'df-added',
removed: 'df-removed',
context: 'df-context',
hunk: 'df-hunk',
meta: 'df-meta',
}
/**
* Render a single DiffFile into an HTMLElement.
*
* Security: ALL text content is set via textContent — zero innerHTML.
* <script>, ANSI sequences, & etc. are rendered as literal characters.
*/
export function renderDiffFile(file: DiffFile): HTMLElement {
const section = el('div', 'df-file')
// ── file header ──────────────────────────────────────────────────────────
const header = el('div', 'df-file-header')
const pathEl = el('span', 'df-path')
if (file.status === 'renamed' && file.oldPath !== file.newPath) {
pathEl.textContent = `${file.oldPath}${file.newPath}`
} else {
pathEl.textContent = file.newPath
}
const statsEl = el('span', 'df-stats')
const addedEl = el('span', 'df-stat-added', `+${file.added}`)
const removedEl = el('span', 'df-stat-removed', `-${file.removed}`)
statsEl.append(addedEl, removedEl)
const statusEl = el('span', `df-status df-status-${file.status}`, file.status)
header.append(pathEl, statsEl, statusEl)
section.append(header)
// ── binary indicator ─────────────────────────────────────────────────────
if (file.binary) {
section.append(el('div', 'df-binary', 'Binary file'))
return section
}
// ── hunks ─────────────────────────────────────────────────────────────────
for (const hunk of file.hunks) {
section.append(renderHunk(hunk))
}
return section
}
function renderHunk(hunk: DiffHunk): HTMLElement {
const hunkEl = el('div', 'df-hunk-block')
// Hunk header (e.g. "@@ -1,3 +1,4 @@")
hunkEl.append(el('div', 'df-hunk-header', hunk.header))
const linesEl = el('div', 'df-lines')
for (const line of hunk.lines) {
linesEl.append(renderLine(line))
}
hunkEl.append(linesEl)
return hunkEl
}
function renderLine(line: DiffLine): HTMLElement {
const cls = LINE_KIND_CLASS[line.kind] ?? 'df-context'
return el('div', `df-line ${cls}`, line.text)
}
/* ── renderDiff ──────────────────────────────────────────────────────────────── */
/**
* Render a full DiffResult: all files grouped, with empty state and truncated warning.
*
* Security: ALL content via textContent — zero innerHTML.
*/
export function renderDiff(result: DiffResult): HTMLElement {
const container = el('div', 'df-result')
// Truncated warning
if (result.truncated) {
container.append(
el('div', 'df-truncated-warning', 'Result truncated — diff is too large to display fully.'),
)
}
// Empty state
if (result.files.length === 0) {
container.append(el('div', 'df-empty', EMPTY_MESSAGE))
return container
}
for (const file of result.files) {
container.append(renderDiffFile(file))
}
return container
}
/* ── mountDiffViewer ─────────────────────────────────────────────────────────── */
/** Handle returned by mountDiffViewer for programmatic control. */
export interface DiffViewerHandle {
/** Switch to the working-tree (unstaged) diff. */
showWorking(): void
/** Switch to the staged diff. */
showStaged(): void
/** Programmatically trigger the onClose callback. */
close(): void
/** Remove all DOM content from the container (cleanup). */
destroy(): void
}
export interface MountDiffViewerOpts {
/** Called when the viewer's close button or close() is invoked. */
onClose?: () => void
}
/**
* Mount a diff viewer into `container`.
*
* Renders a toolbar (Working / Staged toggle + Close), then fetches and renders
* the diff below it. The toggle re-fetches when switched.
*
* Security: SEC-H4 — all content via textContent/el(). Zero innerHTML.
*/
export function mountDiffViewer(
container: HTMLElement,
repoPath: string,
opts: MountDiffViewerOpts,
): DiffViewerHandle {
let destroyed = false
let staged = false
// ── skeleton ──────────────────────────────────────────────────────────────
const root = el('div', 'df-viewer')
// Toolbar
const toolbar = el('div', 'df-toolbar')
const workingBtn = el('button', 'df-tab df-tab-active', 'Working')
const stagedBtn = el('button', 'df-tab', 'Staged')
const closeBtn = el('button', 'df-close', '✕ Close')
toolbar.append(workingBtn, stagedBtn, closeBtn)
root.append(toolbar)
// Content area
const content = el('div', 'df-content')
root.append(content)
container.append(root)
// ── event handlers ────────────────────────────────────────────────────────
workingBtn.addEventListener('click', () => {
if (!staged) return
staged = false
workingBtn.className = 'df-tab df-tab-active'
stagedBtn.className = 'df-tab'
void loadDiff()
})
stagedBtn.addEventListener('click', () => {
if (staged) return
staged = true
stagedBtn.className = 'df-tab df-tab-active'
workingBtn.className = 'df-tab'
void loadDiff()
})
closeBtn.addEventListener('click', () => {
opts.onClose?.()
})
// ── fetch + render ────────────────────────────────────────────────────────
async function loadDiff(): Promise<void> {
if (destroyed) return
content.textContent = 'Loading…'
const result = await fetchDiff(repoPath, staged)
if (destroyed) return
content.textContent = ''
if (result === null) {
content.append(el('div', 'df-error', 'Failed to load diff.'))
} else {
content.append(renderDiff(result))
}
}
void loadDiff()
// ── handle ────────────────────────────────────────────────────────────────
return {
showWorking() {
if (staged) {
staged = false
workingBtn.className = 'df-tab df-tab-active'
stagedBtn.className = 'df-tab'
void loadDiff()
}
},
showStaged() {
if (!staged) {
staged = true
stagedBtn.className = 'df-tab df-tab-active'
workingBtn.className = 'df-tab'
void loadDiff()
}
},
close() {
opts.onClose?.()
},
destroy() {
destroyed = true
container.textContent = ''
},
}
}

View File

@@ -22,9 +22,10 @@
* Tab \t complete / toggle * Tab \t complete / toggle
* ← → \x1b[D/C cursor * ← → \x1b[D/C cursor
* / / slash-command launcher * / / slash-command launcher
* 🎤 (voice) push-to-talk mic (A2; only when SpeechRecognition supported)
*/ */
import type { MountKeybar } from '../src/types.js' import { isSpeechSupported } from './voice.js'
/** Key name → byte string mapping (pure data, for testing). */ /** Key name → byte string mapping (pure data, for testing). */
export const KEY_MAP = { export const KEY_MAP = {
@@ -78,8 +79,32 @@ export const KEYBAR_BUTTONS: KeybarButton[] = [
{ label: '/', caption: '命令', keyName: 'slash', title: 'Slash — command launcher' }, { label: '/', caption: '命令', keyName: 'slash', title: 'Slash — command launcher' },
] ]
/** Mount the key bar: create buttons in #keybar, bind touchstart → onSend(bytes) + preventDefault. */ /**
export const mountKeybar: MountKeybar = (onSend) => { * Options for mountKeybar (A2 voice extension).
*
* All fields are optional so existing callers that pass only `onSend`
* continue to work unchanged.
*/
export interface KeybarOpts {
/**
* Push-to-talk callback. Called with 'start' on touchstart/mousedown and
* 'stop' on touchend/mouseup. The 🎤 button is only rendered when speech
* recognition is supported by the browser AND this callback is supplied.
*
* SEC-L2: Chrome Web Speech forwards audio to Google's servers — the button
* title discloses this so the user can make an informed choice.
*/
onVoiceTrigger?: (action: 'start' | 'stop') => void
}
/**
* Mount the key bar: create buttons in #keybar, bind touchstart → onSend(bytes) +
* preventDefault (keeps the soft keyboard hidden on mobile).
*
* A2: when SpeechRecognition is supported and opts.onVoiceTrigger is provided,
* appends a 🎤 push-to-talk button at the end of the bar.
*/
export function mountKeybar(onSend: (data: string) => void, opts?: KeybarOpts): void {
const keybarEl = document.getElementById('keybar') const keybarEl = document.getElementById('keybar')
if (!keybarEl) return if (!keybarEl) return
@@ -115,4 +140,50 @@ export const mountKeybar: MountKeybar = (onSend) => {
keybarEl.appendChild(btn) keybarEl.appendChild(btn)
} }
// A2: voice mic button — only when speech is supported and caller provides a trigger.
if (isSpeechSupported() && opts?.onVoiceTrigger) {
keybarEl.appendChild(buildMicButton(opts.onVoiceTrigger))
}
}
/**
* Build the 🎤 push-to-talk button element.
* SEC-L2: title discloses that Chrome sends audio to Google.
*/
function buildMicButton(onVoiceTrigger: (action: 'start' | 'stop') => void): HTMLButtonElement {
const btn = document.createElement('button')
btn.classList.add('keybar-btn')
btn.dataset.key = 'voice'
btn.title = 'Hold to dictate — audio processed by browser speech engine (Chrome: Google)'
btn.setAttribute('aria-label', 'Push to talk')
const keyEl = document.createElement('span')
keyEl.className = 'kb-key'
keyEl.textContent = '🎤'
const capEl = document.createElement('span')
capEl.className = 'kb-cap'
capEl.textContent = '语音'
btn.append(keyEl, capEl)
// push-to-talk: start on press, stop on release — preventDefault keeps soft keyboard hidden.
btn.addEventListener('touchstart', (e) => {
e.preventDefault()
onVoiceTrigger('start')
})
btn.addEventListener('touchend', (e) => {
e.preventDefault()
onVoiceTrigger('stop')
})
// Desktop fallback: mousedown/mouseup for push-to-talk semantics.
btn.addEventListener('mousedown', (e) => {
e.preventDefault()
onVoiceTrigger('start')
})
btn.addEventListener('mouseup', (e) => {
e.preventDefault()
onVoiceTrigger('stop')
})
return btn
} }

View File

@@ -9,7 +9,7 @@
*/ */
import { Terminal } from '@xterm/xterm' import { Terminal } from '@xterm/xterm'
import type { LiveSessionInfo } from '../src/types.js' import type { LiveSessionInfo, StatusTelemetry, ClaudeStatus } from '../src/types.js'
/** Shape of GET /live-sessions/:id/preview. */ /** Shape of GET /live-sessions/:id/preview. */
export interface SessionPreview { export interface SessionPreview {
@@ -44,9 +44,13 @@ export function relTime(ms: number): string {
return `${Math.floor(s / 86400)}d` return `${Math.floor(s / 86400)}d`
} }
/** Human label for a Claude activity status. */ /** Human label for a Claude activity status. Supports all ClaudeStatus values including 'stuck'. */
export function statusText(s: LiveSessionInfo['status']): string { export function statusText(s: LiveSessionInfo['status']): string {
return s === 'working' ? '⚙ working' : s === 'waiting' ? '⏳ waiting' : s === 'idle' ? '✓ idle' : '·' if (s === 'working') return '⚙ working'
if (s === 'waiting') return '⏳ waiting'
if (s === 'idle') return '✓ idle'
if (s === 'stuck') return '⚠ stuck'
return '·'
} }
/** Display name for a session: last cwd segment, else the short id. */ /** Display name for a session: last cwd segment, else the short id. */
@@ -178,3 +182,86 @@ export async function fetchLiveSessions(): Promise<LiveSessionInfo[]> {
return [] return []
} }
} }
/**
* Render a telemetry gauge into `container` (clears first).
*
* Shows: context-usage bar (>80% = warning colour), $cost chip, model chip,
* and a PR badge. When `telemetry.at` is older than `staleTtlMs` the container
* receives the class `tg-stale` so CSS can grey it out.
*
* Security: all telemetry strings are set via `textContent` (SEC-H5); the PR
* link href is only set when `url.protocol === 'https:'` (SEC-L5).
* Zero `innerHTML` anywhere.
*/
export function renderTelemetryGauge(
container: HTMLElement,
telemetry: StatusTelemetry | null,
staleTtlMs: number,
): void {
// Clear existing children
while (container.firstChild) container.removeChild(container.firstChild)
if (!telemetry) {
container.classList.remove('tg-stale')
return
}
const isStale = Date.now() - telemetry.at > staleTtlMs
if (isStale) container.classList.add('tg-stale')
else container.classList.remove('tg-stale')
// Context-usage bar
if (telemetry.contextUsedPct !== undefined) {
const bar = el('div', 'tg-ctx-bar')
const fill = el('div', 'tg-ctx-fill')
fill.style.width = `${Math.min(100, telemetry.contextUsedPct)}%`
if (telemetry.contextUsedPct > 80) fill.classList.add('tg-ctx-warn')
bar.append(fill, el('span', 'tg-ctx-label', `ctx ${Math.round(telemetry.contextUsedPct)}%`))
container.append(bar)
}
// Cost chip
if (telemetry.costUsd !== undefined) {
container.append(el('span', 'tg-cost', `$${telemetry.costUsd.toFixed(4)}`))
}
// Model chip
if (telemetry.model !== undefined) {
container.append(el('span', 'tg-model', telemetry.model))
}
// PR badge
if (telemetry.pr !== undefined) {
const badge = el('span', 'tg-pr')
const link = el('a', 'tg-pr-link')
link.textContent = `PR #${telemetry.pr.number}`
// SEC-L5: only set href for https URLs
try {
const parsed = new URL(telemetry.pr.url)
if (parsed.protocol === 'https:') {
link.href = telemetry.pr.url
link.target = '_blank'
link.rel = 'noopener noreferrer'
}
} catch {
// Invalid URL — leave href unset
}
badge.append(link)
if (telemetry.pr.reviewState !== undefined) {
badge.append(el('span', 'tg-pr-state', telemetry.pr.reviewState))
}
container.append(badge)
}
}
/**
* Render a Claude status badge into `container` (clears first).
* 'stuck' shows the ⚠ warning symbol (A5). Uses only textContent (SEC-H5).
*/
export function renderStatusBadge(container: HTMLElement, status: ClaudeStatus): void {
while (container.firstChild) container.removeChild(container.firstChild)
container.append(el('span', `sb-badge sb-${status}`, statusText(status)))
}

View File

@@ -19,9 +19,12 @@ import type {
ClaudeStatus, ClaudeStatus,
ProjectDetail, ProjectDetail,
WorktreeInfo, WorktreeInfo,
CreateWorktreeResult,
} from '../src/types.js' } from '../src/types.js'
import { el, relTime, statusText } from './preview-grid.js' import { el, relTime, statusText } from './preview-grid.js'
import { CLAUDE_LOGO, CODEX_LOGO, VSCODE_LOGO } from './icons.js' import { CLAUDE_LOGO, CODEX_LOGO, VSCODE_LOGO } from './icons.js'
import { mountDiffViewer, type DiffViewerHandle } from './diff.js'
import { mountTimeline, type TimelineHandle } from './timeline.js'
/* ── Constants ───────────────────────────────────────────────────────────── */ /* ── Constants ───────────────────────────────────────────────────────────── */
@@ -405,21 +408,166 @@ function makeDetailSessionRow(
return row return row
} }
/** Returns null if `branch` is a valid git branch name, or an error message (B3). Never throws. */
export function validateBranchNameClient(branch: string): string | null {
if (!branch) return 'Branch name cannot be empty'
if (branch.length > 250) return 'Branch name is too long (max 250 characters)'
if (/[\x00-\x20\x7f]/.test(branch)) return 'Branch name cannot contain spaces or control characters'
if (branch.startsWith('-')) return 'Branch name cannot start with a hyphen'
if (branch.includes('..')) return 'Branch name cannot contain ".."'
if (branch.endsWith('.lock')) return 'Branch name cannot end with ".lock"'
if (/[~^:?*\[\\]/.test(branch)) return 'Branch name contains invalid characters'
if (branch.includes('@{')) return 'Branch name cannot contain "@{"'
if (branch.startsWith('/') || branch.endsWith('/') || branch.includes('//')) {
return 'Branch name has invalid slash usage'
}
return null
}
function isWorktreeResult(v: unknown): v is CreateWorktreeResult {
return v !== null && typeof v === 'object' && typeof (v as Record<string, unknown>)['ok'] === 'boolean'
}
/** Render a "New Worktree" form (B3). Errors via textContent only (SEC-L3/H6). */
export function renderNewWorktreeForm(detail: ProjectDetail, hooks: ProjectsHooks): HTMLElement {
const form = el('div', 'proj-wt-form')
const input = document.createElement('input')
input.type = 'text'
input.className = 'proj-wt-branch-input'
input.placeholder = 'New branch name'
input.setAttribute('aria-label', 'New branch name')
input.maxLength = 250
const errorEl = el('div', 'proj-wt-error')
errorEl.style.display = 'none'
const submitBtn = el('button', 'proj-wt-submit', 'Create Worktree')
form.append(input, errorEl, submitBtn)
function showError(msg: string): void {
errorEl.textContent = msg // SEC-L3/H6: textContent, never innerHTML
errorEl.style.display = ''
submitBtn.disabled = false
}
function hideError(): void { errorEl.textContent = ''; errorEl.style.display = 'none' }
let busy = false
async function doCreate(): Promise<void> {
if (busy) return
const branch = input.value.trim()
const err = validateBranchNameClient(branch)
if (err !== null) { showError(err); return }
hideError()
busy = true
submitBtn.disabled = true
try {
const res = await fetch('/projects/worktree', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ repoPath: detail.path, branch }),
})
let data: unknown
try { data = await res.json() } catch { data = null }
if (!res.ok || !isWorktreeResult(data) || !data.ok) {
showError(isWorktreeResult(data) && typeof data.error === 'string'
? data.error : 'Failed to create worktree')
return
}
hooks.onOpenProject(data.path ?? detail.path, data.branch ?? branch, 'claude\r')
} catch {
showError('Failed to create worktree')
} finally {
busy = false
submitBtn.disabled = false
}
}
submitBtn.addEventListener('click', () => { void doCreate() })
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') void doCreate() })
input.addEventListener('input', () => { if (errorEl.style.display !== 'none') hideError() })
return form
}
/** Build the "View Diff" toggle + inline panel (B1). `diffRef.h` tracks the open handle. */
function buildDiffSection(
repoPath: string,
diffRef: { h: DiffViewerHandle | null },
): HTMLElement {
const section = el('div', 'proj-diff-section')
const toggle = el('button', 'proj-diff-toggle', 'View Diff')
const panel = el('div', 'proj-diff-panel')
panel.style.display = 'none'
toggle.addEventListener('click', () => {
if (panel.style.display === 'none') {
panel.style.display = ''
toggle.textContent = 'Hide Diff'
diffRef.h = mountDiffViewer(panel, repoPath, {
onClose: () => {
panel.style.display = 'none'
toggle.textContent = 'View Diff'
diffRef.h?.destroy()
diffRef.h = null
},
})
} else {
diffRef.h?.destroy()
diffRef.h = null
panel.style.display = 'none'
toggle.textContent = 'View Diff'
}
})
section.append(toggle, panel)
return section
}
/** Build the "Activity" section: one timeline panel per running session (A4). */
function buildActivitySection(
running: ProjectSessionRef[],
localTimelines: TimelineHandle[],
collector: TimelineHandle[] | undefined,
): HTMLElement | null {
if (running.length === 0) return null
const section = el('div', 'proj-activity-section')
for (const sess of running) {
const block = el('div', 'proj-activity-session')
block.append(el('div', 'proj-activity-label', sess.title ?? 'session'))
const tlContainer = el('div', 'proj-tl-container')
block.append(tlContainer)
const handle = mountTimeline(tlContainer, sess.id)
localTimelines.push(handle)
if (collector !== undefined) collector.push(handle)
section.append(block)
}
return section
}
interface DetailCallbacks { interface DetailCallbacks {
onBack: () => void onBack: () => void
onKill: (id: string) => void onKill: (id: string) => void
} }
/** Render the project detail view (exported for unit tests). */ /** Render the project detail view (exported for unit tests). `timelineCollector` receives A4 handles for re-render disposal. */
export function renderProjectDetail( export function renderProjectDetail(
detail: ProjectDetail | null, detail: ProjectDetail | null,
hooks: ProjectsHooks, hooks: ProjectsHooks,
cb: DetailCallbacks, cb: DetailCallbacks,
timelineCollector?: TimelineHandle[],
): HTMLElement { ): HTMLElement {
const root = el('div', 'proj-detail-inner') const root = el('div', 'proj-detail-inner')
const localTimelines: TimelineHandle[] = []
const diffRef: { h: DiffViewerHandle | null } = { h: null }
const back = el('button', 'proj-back', '← All projects') const back = el('button', 'proj-back', '← All projects')
back.addEventListener('click', () => cb.onBack()) back.addEventListener('click', () => {
diffRef.h?.destroy()
diffRef.h = null
for (const handle of localTimelines) handle.dispose()
cb.onBack()
})
root.append(back) root.append(back)
if (detail === null) { if (detail === null) {
@@ -439,6 +587,9 @@ export function renderProjectDetail(
root.append(head) root.append(head)
root.append(el('div', 'proj-detail-path', detail.path)) root.append(el('div', 'proj-detail-path', detail.path))
// B1: View Diff (git repos only) — SEC-H4 enforced inside mountDiffViewer
if (detail.isGit) root.append(buildDiffSection(detail.path, diffRef))
// Branch / worktrees // Branch / worktrees
root.append(el('div', 'proj-section-title', detail.worktrees.length > 1 ? 'Worktrees' : 'Branch')) root.append(el('div', 'proj-section-title', detail.worktrees.length > 1 ? 'Worktrees' : 'Branch'))
if (!detail.isGit) { if (!detail.isGit) {
@@ -453,6 +604,12 @@ export function renderProjectDetail(
root.append(list) root.append(list)
} }
// B3: New Worktree form (git repos only)
if (detail.isGit) {
root.append(el('div', 'proj-section-title', 'New Worktree'))
root.append(renderNewWorktreeForm(detail, hooks))
}
// Active sessions // Active sessions
const running = detail.sessions.filter((s) => !s.exited) const running = detail.sessions.filter((s) => !s.exited)
root.append(el('div', 'proj-section-title', `Active sessions (${running.length})`)) root.append(el('div', 'proj-section-title', `Active sessions (${running.length})`))
@@ -464,15 +621,20 @@ export function renderProjectDetail(
root.append(list) root.append(list)
} }
// CLAUDE.md — view + a smart Generate/Update button that runs /init interactively. // A4: Activity section — one timeline per running session, disposed on back/re-render
const activityEl = buildActivitySection(running, localTimelines, timelineCollector)
if (activityEl !== null) {
root.append(el('div', 'proj-section-title', 'Activity'))
root.append(activityEl)
}
// CLAUDE.md
const cmHead = el('div', 'proj-section-row') const cmHead = el('div', 'proj-section-row')
cmHead.append(el('div', 'proj-section-title', 'CLAUDE.md')) cmHead.append(el('div', 'proj-section-title', 'CLAUDE.md'))
const cmBtn = el('button', 'proj-claudemd-btn', detail.hasClaudeMd ? '↻ Update' : '✨ Generate') const cmBtn = el('button', 'proj-claudemd-btn', detail.hasClaudeMd ? '↻ Update' : '✨ Generate')
cmBtn.title = detail.hasClaudeMd cmBtn.title = detail.hasClaudeMd
? 'Open a Claude session running /init to refresh CLAUDE.md' ? 'Open a Claude session running /init to refresh CLAUDE.md'
: 'Open a Claude session running /init to create CLAUDE.md' : 'Open a Claude session running /init to create CLAUDE.md'
// Launch claude with /init as the initial prompt, in this repo (interactive,
// so you review what it writes). Reuses the project launcher.
cmBtn.addEventListener('click', () => hooks.onOpenProject(detail.path, detail.name, 'claude "/init"\r')) cmBtn.addEventListener('click', () => hooks.onOpenProject(detail.path, detail.name, 'claude "/init"\r'))
cmHead.append(cmBtn) cmHead.append(cmBtn)
root.append(cmHead) root.append(cmHead)
@@ -483,11 +645,7 @@ export function renderProjectDetail(
root.append(pre) root.append(pre)
} else { } else {
root.append( root.append(
el( el('div', 'proj-empty', 'No CLAUDE.md yet — generate one to give Claude project-specific instructions.'),
'div',
'proj-empty',
'No CLAUDE.md yet — generate one to give Claude project-specific instructions.',
),
) )
} }
@@ -527,6 +685,8 @@ export function mountProjects(host: HTMLElement, hooks: ProjectsHooks): Projects
let timer: ReturnType<typeof setInterval> | null = null let timer: ReturnType<typeof setInterval> | null = null
let view: 'grid' | 'detail' = 'grid' let view: 'grid' | 'detail' = 'grid'
let detailPath: string | null = null let detailPath: string | null = null
// A4: tracks timeline handles from the current detail render for re-render disposal
let detailTimelineHandles: TimelineHandle[] = []
function applyView(): void { function applyView(): void {
const isGrid = view === 'grid' const isGrid = view === 'grid'
@@ -587,15 +747,23 @@ export function mountProjects(host: HTMLElement, hooks: ProjectsHooks): Projects
} }
function renderDetail(detail: ProjectDetail | null): void { function renderDetail(detail: ProjectDetail | null): void {
// Dispose timelines from the previous render before replacing DOM (A4 re-render)
for (const h of detailTimelineHandles) h.dispose()
detailTimelineHandles = []
detailEl.replaceChildren( detailEl.replaceChildren(
renderProjectDetail(detail, hooks, { renderProjectDetail(
onBack: closeDetail, detail,
onKill: (id) => { hooks,
void killSession(id).then(() => { {
if (view === 'detail') void refresh() onBack: closeDetail,
}) onKill: (id) => {
void killSession(id).then(() => {
if (view === 'detail') void refresh()
})
},
}, },
}), detailTimelineHandles,
),
) )
} }

354
public/push.ts Normal file
View File

@@ -0,0 +1,354 @@
/**
* public/push.ts — Push subscribe/permission UI (N-push-ui, A1)
*
* Exports:
* PushSupportStatus — union of all push readiness states
* fetchVapidKey() — GET /push/vapid-key (null on 503/error)
* checkPushSupport(vapidKey) — async, checks all preconditions (SEC-H8)
* subscribePush(vapidKey) — request permission + SW subscribe + POST server
* unsubscribePush(sub) — DELETE server + browser unsubscribe (best-effort)
* mountPushToggle(container, opts?) — render 🔔 toggle widget
* isPushMuted() / setPushMuted(muted) — in-app mute (A1-FR9, localStorage only)
*
* SEC-H8: isSecureContext is the first check in checkPushSupport; nothing push-
* related executes in an insecure context.
* Review #12: The localStorage mute is in-app-only. Global DND is server-side
* (NOTIFY_DND env). They are independent.
*/
/* ── Types ─────────────────────────────────────────────────────────────────── */
/** All possible push-readiness states. Drives UI rendering in mountPushToggle. */
export type PushSupportStatus =
| 'unsupported' // SW / PushManager / Notification API unavailable in this browser
| 'insecure-context' // page is not a secure context; needs HTTPS or Tailscale (SEC-H8)
| 'vapid-missing' // server has no VAPID keys; GET /push/vapid-key returned 503
| 'permission-denied' // user blocked notifications in browser settings
| 'available' // all checks pass, ready to subscribe (no active sub yet)
| 'subscribed' // active push subscription already exists
export interface MountPushToggleOpts {
/** Called once after the status is resolved and the widget is rendered. */
onChange?: (status: PushSupportStatus) => void
}
/* ── In-app mute (A1-FR9) ──────────────────────────────────────────────────── */
/** localStorage key for the in-app mute preference. */
const PUSH_MUTE_KEY = 'web-terminal:push-muted'
/** Read the in-app mute preference. Returns false on any storage error. */
export function isPushMuted(): boolean {
try {
return localStorage.getItem(PUSH_MUTE_KEY) === '1'
} catch {
return false
}
}
/** Write the in-app mute preference. Silently ignores storage errors. */
export function setPushMuted(muted: boolean): void {
try {
if (muted) {
localStorage.setItem(PUSH_MUTE_KEY, '1')
} else {
localStorage.removeItem(PUSH_MUTE_KEY)
}
} catch {
// Ignore — private browsing or storage quota; best-effort
}
}
/* ── fetchVapidKey ─────────────────────────────────────────────────────────── */
/**
* Fetch the VAPID public key from the server.
* Returns null when push is disabled (503) or on any error.
*/
export async function fetchVapidKey(): Promise<string | null> {
try {
const res = await fetch('/push/vapid-key', { credentials: 'same-origin' })
if (!res.ok) return null
const data: unknown = await res.json()
if (
typeof data === 'object' &&
data !== null &&
'publicKey' in data &&
typeof (data as Record<string, unknown>)['publicKey'] === 'string'
) {
return (data as { publicKey: string }).publicKey
}
return null
} catch {
return null
}
}
/* ── checkPushSupport ──────────────────────────────────────────────────────── */
/**
* Check the comprehensive push support status. Checks in order:
* insecure-context → unsupported → vapid-missing → permission-denied
* → subscribed → available
*
* SEC-H8: isSecureContext is the first check.
*/
export async function checkPushSupport(vapidKey: string | null): Promise<PushSupportStatus> {
// SEC-H8: secure context is required for SW and Push API
if (!window.isSecureContext) return 'insecure-context'
// Require all three browser APIs — check values (not just property existence),
// because jsdom sets properties to undefined rather than deleting them.
const hasSW = Boolean(navigator.serviceWorker)
// Cast via `unknown` first to avoid TS2352 (Window lacks an index signature).
const winMap = window as unknown as Record<string, unknown>
const hasPushMgr = Boolean(winMap['PushManager'])
// Capture Notification locally so we can access .permission safely below.
const Notif = winMap['Notification'] as typeof Notification | undefined
if (!hasSW || !hasPushMgr || !Notif) return 'unsupported'
// Server must have VAPID keys configured
if (vapidKey === null) return 'vapid-missing'
// Check browser permission
if (Notif.permission === 'denied') return 'permission-denied'
// Check for an existing active subscription
try {
const registration = await navigator.serviceWorker.getRegistration()
if (registration) {
const subscription = await registration.pushManager.getSubscription()
if (subscription) return 'subscribed'
}
} catch {
// Cannot determine subscription state; fall through to 'available'
}
return 'available'
}
/* ── subscribePush ─────────────────────────────────────────────────────────── */
/** Convert a URL-safe base64 string to Uint8Array for applicationServerKey. */
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = window.atob(base64)
const output = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
output[i] = rawData.charCodeAt(i) ?? 0
}
return output
}
/**
* Subscribe to push notifications.
* 1. Requests Notification permission (if not already granted).
* 2. Gets the SW registration and calls pushManager.subscribe().
* 3. POSTs the PushSubscription to /push/subscribe.
* Returns the PushSubscription on success, null on any failure.
*/
export async function subscribePush(vapidKey: string): Promise<PushSubscription | null> {
try {
const perm = await Notification.requestPermission()
if (perm !== 'granted') return null
const registration = await navigator.serviceWorker.getRegistration()
if (!registration) return null
const applicationServerKey = urlBase64ToUint8Array(vapidKey)
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
// Cast to `ArrayBuffer` — Uint8Array.buffer is ArrayBufferLike which includes
// SharedArrayBuffer, but DOM types require ArrayBuffer here.
applicationServerKey: applicationServerKey.buffer as ArrayBuffer,
})
const res = await fetch('/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(subscription.toJSON()),
})
if (!res.ok) {
// Roll back: unsubscribe from browser so we stay in sync
await subscription.unsubscribe()
return null
}
return subscription
} catch {
return null
}
}
/* ── unsubscribePush ───────────────────────────────────────────────────────── */
/**
* Unsubscribe from push notifications.
* Both operations are best-effort; individual errors are swallowed so the
* other operation still runs.
*/
export async function unsubscribePush(subscription: PushSubscription): Promise<void> {
try {
await fetch('/push/subscribe', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ endpoint: subscription.endpoint }),
})
} catch {
// Ignore server errors — still attempt browser unsubscribe
}
try {
await subscription.unsubscribe()
} catch {
// Ignore browser errors
}
}
/* ── DOM helpers ───────────────────────────────────────────────────────────── */
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
cls?: string,
text?: string,
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag)
if (cls) node.className = cls
if (text !== undefined) node.textContent = text
return node
}
/* ── mountPushToggle ───────────────────────────────────────────────────────── */
/**
* Mount a 🔔 push subscribe/unsubscribe toggle into container.
*
* Behavior per status:
* 'vapid-missing' → hides container (server has no VAPID keys)
* 'insecure-context' → grayed 🔔 + "needs HTTPS/Tailscale" hint
* 'unsupported' → grayed 🔔 + "not supported in this browser" hint
* 'permission-denied' → grayed 🔔 + "blocked in browser settings" hint
* 'available' → active button (Off state) to initiate subscribe
* 'subscribed' → active button (On state) to initiate unsubscribe
*/
export function mountPushToggle(container: HTMLElement, opts?: MountPushToggleOpts): void {
void initPushToggle(container, opts)
}
async function initPushToggle(container: HTMLElement, opts?: MountPushToggleOpts): Promise<void> {
const vapidKey = await fetchVapidKey()
const status = await checkPushSupport(vapidKey)
renderPushToggle(container, status, vapidKey, opts)
}
function renderPushToggle(
container: HTMLElement,
status: PushSupportStatus,
vapidKey: string | null,
opts?: MountPushToggleOpts,
): void {
// Clear existing children
while (container.firstChild) container.removeChild(container.firstChild)
opts?.onChange?.(status)
if (status === 'vapid-missing') {
// Server push is disabled — hide the widget entirely
container.style.display = 'none'
return
}
container.style.display = ''
if (status === 'insecure-context') {
renderDisabled(
container,
'Push notifications unavailable',
'Enable push: access via HTTPS or Tailscale',
)
return
}
if (status === 'unsupported') {
renderDisabled(
container,
'Push notifications not supported',
'Push notifications not supported in this browser',
)
return
}
if (status === 'permission-denied') {
renderDisabled(
container,
'Push notifications blocked',
'Notifications blocked — allow in browser settings',
)
return
}
// 'available' or 'subscribed' — render a functional toggle button
renderToggleButton(container, status === 'subscribed', vapidKey, opts)
}
function renderDisabled(container: HTMLElement, ariaLabel: string, hint: string): void {
const wrap = el('span', 'push-toggle-disabled')
const bell = el('span', 'push-bell push-bell-off', '🔔')
bell.setAttribute('aria-label', ariaLabel)
const hintEl = el('span', 'push-hint', hint)
wrap.append(bell, hintEl)
container.append(wrap)
}
function renderToggleButton(
container: HTMLElement,
isSubscribed: boolean,
vapidKey: string | null,
opts?: MountPushToggleOpts,
): void {
const btn = el(
'button',
isSubscribed ? 'push-toggle-btn push-toggle-on' : 'push-toggle-btn push-toggle-off',
isSubscribed ? '🔔 On' : '🔔 Off',
)
btn.title = isSubscribed
? 'Click to disable push notifications'
: 'Click to enable push notifications'
btn.setAttribute('aria-pressed', isSubscribed ? 'true' : 'false')
btn.addEventListener('click', () => {
btn.disabled = true
void handleToggleClick(container, vapidKey, isSubscribed, opts)
})
container.append(btn)
}
async function handleToggleClick(
container: HTMLElement,
vapidKey: string | null,
wasSubscribed: boolean,
opts?: MountPushToggleOpts,
): Promise<void> {
if (wasSubscribed) {
try {
const registration = await navigator.serviceWorker.getRegistration()
if (registration) {
const sub = await registration.pushManager.getSubscription()
if (sub) await unsubscribePush(sub)
}
} catch {
// ignore
}
} else if (vapidKey !== null) {
await subscribePush(vapidKey)
}
// Re-check status and re-render
const newStatus = await checkPushSupport(vapidKey)
renderPushToggle(container, newStatus, vapidKey, opts)
}

279
public/quick-reply.ts Normal file
View File

@@ -0,0 +1,279 @@
/**
* public/quick-reply.ts — N-quickreply: quick-reply chips + saved-prompt palette.
*
* Feature: A3 (Walk-away Workbench — "Quick-Reply Chips + Saved Prompt Palette")
*
* Provides:
* - BUILT_IN_CHIPS — pre-set chips for Claude Code's most common responses.
* - Immutable CRUD — addChip / removeChip / reorderChip / updateChip.
* - Palette I/O — loadPalette / savePalette (localStorage, never throw).
* - mountQuickReply — DOM mount: chips + inline editor.
*
* Security: SEC-L3 — all chip labels are set via textContent, never innerHTML,
* so a snippet label such as `<script>alert(1)</script>` 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<Omit<Chip, 'id'>>,
): 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<string, unknown>
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<K extends keyof HTMLElementTagNameMap>(
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)
},
}
}

82
public/sw-push.js Normal file
View File

@@ -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<string, string>} */
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<string, unknown> }}
*/
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<string, unknown>} */
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)}`,
}
}

View File

@@ -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 * Network-first caching + A1 push notifications + notificationclick routing.
* cache when offline. Never intercepts the WebSocket (/term) or hook endpoints. * 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' const CACHE = 'webterm-v1'
self.addEventListener('install', () => self.skipWaiting()) self.addEventListener('install', () => self.skipWaiting())
@@ -14,7 +20,13 @@ self.addEventListener('fetch', (e) => {
const req = e.request const req = e.request
if (req.method !== 'GET') return if (req.method !== 'GET') return
const url = new URL(req.url) 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( e.respondWith(
fetch(req) fetch(req)
@@ -26,3 +38,54 @@ self.addEventListener('fetch', (e) => {
.catch(() => caches.match(req)), .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)
}),
)
}
})

View File

@@ -25,10 +25,25 @@ import { TerminalSession } from './terminal-session.js'
import { THEMES, DEFAULT_SETTINGS, type Settings } from './settings.js' import { THEMES, DEFAULT_SETTINGS, type Settings } from './settings.js'
import { mountLauncher, type Launcher } from './launcher.js' import { mountLauncher, type Launcher } from './launcher.js'
import { mountProjects, type ProjectsPanel } from './projects.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 TABS_KEY = 'web-terminal:tabs'
const ACTIVE_KEY = 'web-terminal:active' const ACTIVE_KEY = 'web-terminal:active'
const HOME_VIEW_KEY = 'web-terminal:home-view' 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' type HomeView = 'sessions' | 'projects'
@@ -38,6 +53,10 @@ interface TabEntry {
autoTitle: string | null // current folder from the terminal title autoTitle: string | null // current folder from the terminal title
hasActivity: boolean // inactive tab got output since last viewed hasActivity: boolean // inactive tab got output since last viewed
el: HTMLDivElement | null // the .tab element (updated in place) 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 { interface StoredTab {
@@ -45,6 +64,15 @@ interface StoredTab {
title: string | null 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 { export class TabApp {
private readonly tabs: TabEntry[] = [] private readonly tabs: TabEntry[] = []
private activeIndex = -1 private activeIndex = -1
@@ -64,6 +92,15 @@ export class TabApp {
// tab is activated. Irrelevant while no tab is open (home shows regardless). // tab is activated. Irrelevant while no tab is open (home shows regardless).
private homeForced = false private homeForced = false
private homeBtn: HTMLButtonElement | null = null 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) { constructor(paneHost: HTMLElement, tabBar: HTMLElement) {
this.paneHost = paneHost this.paneHost = paneHost
@@ -94,6 +131,13 @@ export class TabApp {
this.segControl = this.buildSegControl() this.segControl = this.buildSegControl()
this.paneHost.appendChild(this.segControl) 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; // 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 // the user picks which session to open. Sessions persist server-side, so
// opening one replays its full scrollback. // opening one replays its full scrollback.
@@ -101,6 +145,61 @@ export class TabApp {
this.updateHomeView() 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<void> {
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<string, unknown>)['allowAutoMode'] === 'boolean'
) {
this.allowAutoMode = (data as UiConfig).allowAutoMode
}
} catch {
// best-effort — leave allowAutoMode false (auto hidden) on any failure
}
}
/* ── Home-view visibility ────────────────────────────────────────── */ /* ── Home-view visibility ────────────────────────────────────────── */
/** /**
@@ -189,7 +288,8 @@ export class TabApp {
/* ── Approval bar ───────────────────────────────────────────────── */ /* ── 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 { private updateApprovalBar(): void {
const session = this.tabs[this.activeIndex]?.session const session = this.tabs[this.activeIndex]?.session
if (!session || !session.pendingApproval) { if (!session || !session.pendingApproval) {
@@ -199,25 +299,150 @@ export class TabApp {
this.approvalBar.replaceChildren() this.approvalBar.replaceChildren()
const label = document.createElement('span') const label = document.createElement('span')
label.className = 'approval-label' label.className = 'approval-label'
label.textContent = `Claude wants to use ${session.pendingTool ?? 'a tool'}` if (session.pendingGate === 'plan') {
const approve = document.createElement('button') label.textContent = 'Claude finished planning — how should it proceed?'
approve.className = 'approval-yes' this.approvalBar.append(label, ...this.planGateButtons(session))
approve.textContent = '✓ Approve' } else {
approve.addEventListener('click', () => { label.textContent = `Claude wants to use ${session.pendingTool ?? 'a tool'}`
session.approve() this.approvalBar.append(label, ...this.toolGateButtons(session))
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)
this.approvalBar.style.display = 'flex' 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 { private displayTitle(entry: TabEntry, idx: number): string {
return entry.customTitle ?? entry.autoTitle ?? `Term ${idx + 1}` return entry.customTitle ?? entry.autoTitle ?? `Term ${idx + 1}`
} }
@@ -287,8 +512,17 @@ export class TabApp {
this.notify(entry) 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.paneHost.appendChild(session.el)
this.tabs.push(entry) this.tabs.push(entry)
session.applyTheme(THEMES[this.settings.theme] ?? THEMES['dark']!, this.settings.fontSize) 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 * distinguish them (e.g. "web-terminal #2"). Mirrors newTabForResume in
* structure: addEntry → persist → rebuild → activate. * 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 n = this.countOpenWithTitlePrefix(repoName)
const label = n === 0 ? repoName : `${repoName} #${n + 1}` 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 <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.persist()
this.rebuild() this.rebuild()
this.activate(this.tabs.length - 1) 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.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.updateHomeView() // hide the seg control + home panels now a tab is active
this.updateApprovalBar() this.updateApprovalBar()
if (this.timelineOpen) this.openTimelineForActive() // A4: follow the active session
this.persist() this.persist()
} }
closeTab(i: number): void { closeTab(i: number): void {
if (i < 0 || i >= this.tabs.length) return if (i < 0 || i >= this.tabs.length) return
const [entry] = this.tabs.splice(i, 1) const [entry] = this.tabs.splice(i, 1)
entry?.timelineHandle?.dispose() // A4: stop polling for the closed tab
entry?.session.dispose() entry?.session.dispose()
if (this.editingIndex === i) this.editingIndex = -1 if (this.editingIndex === i) this.editingIndex = -1
if (this.tabs.length === 0) { if (this.tabs.length === 0) {
@@ -464,7 +703,9 @@ export class TabApp {
/* ── rendering ───────────────────────────────────────────────────── */ /* ── rendering ───────────────────────────────────────────────────── */
/** In-place update of one tab's classes/label/dotnever 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 { private refreshTab(entry: TabEntry): void {
const el = entry.el const el = entry.el
if (!el) return if (!el) return
@@ -473,6 +714,7 @@ export class TabApp {
el.classList.toggle('unread', entry.hasActivity && idx !== this.activeIndex) el.classList.toggle('unread', entry.hasActivity && idx !== this.activeIndex)
const cs = entry.session.claudeStatus const cs = entry.session.claudeStatus
el.classList.toggle('claude-waiting', cs === 'waiting' && idx !== this.activeIndex) 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) const title = this.displayTitle(entry, idx)
el.title = title el.title = title
const dot = el.querySelector('.tab-dot') const dot = el.querySelector('.tab-dot')
@@ -480,10 +722,9 @@ export class TabApp {
const label = el.querySelector('.tab-label') const label = el.querySelector('.tab-label')
if (label) label.textContent = title if (label) label.textContent = title
const claude = el.querySelector('.tab-claude') const claude = el.querySelector('.tab-claude')
if (claude) { if (claude) claude.textContent = claudeIcon(cs)
claude.textContent = const gauge = el.querySelector<HTMLElement>('.tab-gauge')
cs === 'working' ? '⚙' : cs === 'waiting' ? '⏳' : cs === 'idle' ? '✓' : '' if (gauge) renderTelemetryGauge(gauge, entry.session.telemetry, STATUSLINE_TTL_MS) // B2
}
} }
/** Full rebuild — ONLY for structural changes (add/close/reorder/rename). */ /** Full rebuild — ONLY for structural changes (add/close/reorder/rename). */
@@ -577,6 +818,11 @@ export class TabApp {
claude.className = 'tab-claude' claude.className = 'tab-claude'
tabEl.appendChild(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') const close = document.createElement('button')
close.className = 'tab-close' close.className = 'tab-close'
close.textContent = '×' close.textContent = '×'
@@ -603,6 +849,19 @@ export class TabApp {
add.addEventListener('click', () => this.newTab()) add.addEventListener('click', () => this.newTab())
this.tabBar.appendChild(add) 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. // Show/hide the home screen based on whether any tab is open.
this.updateHomeView() this.updateHomeView()
} }

View File

@@ -13,7 +13,14 @@ import type { ITheme } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit' import { FitAddon } from '@xterm/addon-fit'
import { SearchAddon } from '@xterm/addon-search' import { SearchAddon } from '@xterm/addon-search'
import { WebLinksAddon } from '@xterm/addon-web-links' 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' import { folderFromTitle, cwdFromOsc7 } from './title-util.js'
// Delay after the shell is ready before typing a session's initial command // Delay after the shell is ready before typing a session's initial command
@@ -56,6 +63,9 @@ export interface TerminalSessionOpts {
onStatus?: (status: SessionStatus) => void onStatus?: (status: SessionStatus) => void
/** Optional: fired when Claude Code activity changes (from a hook, H2). */ /** Optional: fired when Claude Code activity changes (from a hook, H2). */
onClaudeStatus?: (status: ClaudeStatus, detail?: string) => void 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). */ /** Optional: spawn a NEW session in this directory ("new tab here", M6). */
cwd?: string cwd?: string
/** Optional: type this once the new session's shell is ready (O2 resume). */ /** 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 onTitle: ((title: string) => void) | undefined
private readonly onStatus: ((status: SessionStatus) => void) | undefined private readonly onStatus: ((status: SessionStatus) => void) | undefined
private readonly onClaudeStatus: ((status: ClaudeStatus, detail?: string) => 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 spawnCwd: string | undefined
private readonly initialInput: string | undefined private readonly initialInput: string | undefined
private initialSent = false private initialSent = false
@@ -82,6 +93,8 @@ export class TerminalSession {
private cwdValue: string | null = null private cwdValue: string | null = null
private pendingApprovalValue = false private pendingApprovalValue = false
private pendingToolValue: string | undefined = undefined private pendingToolValue: string | undefined = undefined
private telemetryValue: StatusTelemetry | null = null
private pendingGateValue: PermissionGate | null = null
private ws: WebSocket | null = null private ws: WebSocket | null = null
private sessionId: string | null private sessionId: string | null
@@ -102,6 +115,7 @@ export class TerminalSession {
this.onTitle = opts.onTitle this.onTitle = opts.onTitle
this.onStatus = opts.onStatus this.onStatus = opts.onStatus
this.onClaudeStatus = opts.onClaudeStatus this.onClaudeStatus = opts.onClaudeStatus
this.onTelemetry = opts.onTelemetry
this.spawnCwd = opts.cwd this.spawnCwd = opts.cwd
this.initialInput = opts.initialInput this.initialInput = opts.initialInput
@@ -163,6 +177,18 @@ export class TerminalSession {
return this.pendingToolValue 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 { private setStatus(s: SessionStatus): void {
this.statusValue = s this.statusValue = s
this.onStatus?.(s) this.onStatus?.(s)
@@ -270,9 +296,15 @@ export class TerminalSession {
this.claudeStatusValue = msg.status this.claudeStatusValue = msg.status
this.pendingApprovalValue = msg.pending === true this.pendingApprovalValue = msg.pending === true
this.pendingToolValue = msg.pending === true ? msg.detail : undefined this.pendingToolValue = msg.pending === true ? msg.detail : undefined
this.pendingGateValue = msg.gate ?? null
this.onClaudeStatus?.(msg.status, msg.detail) this.onClaudeStatus?.(msg.status, msg.detail)
break break
} }
case 'telemetry': {
this.telemetryValue = msg.telemetry
this.onTelemetry?.(msg.telemetry)
break
}
case 'exit': { case 'exit': {
this.setStatus('exited') this.setStatus('exited')
const reason = msg.reason ? ` (${msg.reason})` : '' const reason = msg.reason ? ` (${msg.reason})` : ''
@@ -294,6 +326,14 @@ export class TerminalSession {
}) })
break 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 }) this.sendMsg({ type: 'input', data })
} }
/** Resolve a held PermissionRequest (H3). */ /** Resolve a held PermissionRequest (H3). Optionally relay a permission-mode
approve(): void { * 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.pendingApprovalValue = false
this.sendMsg({ type: 'approve' }) this.sendMsg({ type: 'approve', ...(mode !== undefined ? { mode } : {}) })
} }
reject(): void { reject(): void {
this.pendingApprovalValue = false this.pendingApprovalValue = false

226
public/timeline.ts Normal file
View File

@@ -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<string> = new Set<TimelineClass>([
'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<string, unknown>
// 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 <div class="tl-row">
* with three child spans: tl-time, tl-icon-<class>, 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<TimelineEvent[]> {
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<typeof setInterval> | 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<void> {
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
}
},
}
}

179
public/voice.ts Normal file
View File

@@ -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
},
}
}

View File

@@ -9,6 +9,14 @@
* $WEBTERM_SESSION (which tab). Outside web-terminal those vars are unset, so * $WEBTERM_SESSION (which tab). Outside web-terminal those vars are unset, so
* curl no-ops (|| true) — the hooks are harmless in normal terminals. * 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: * Usage:
* node scripts/setup-hooks.mjs # install (idempotent) * node scripts/setup-hooks.mjs # install (idempotent)
* node scripts/setup-hooks.mjs --remove # uninstall * node scripts/setup-hooks.mjs --remove # uninstall
@@ -19,69 +27,277 @@
import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync } from 'node:fs' import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync } from 'node:fs'
import { homedir } from 'node:os' import { homedir } from 'node:os'
import path from 'node:path' import path from 'node:path'
import { fileURLToPath } from 'node:url'
const SETTINGS = path.join(homedir(), '.claude', 'settings.json') // ── Constants ────────────────────────────────────────────────────────────────
const MARKER = 'WEBTERM_HOOK_URL' // identifies our hook entries
// Fire-and-forget status events: POST and discard the response. /** Appears in every web-terminal hook command — used by cleanup to identify our entries. */
const FF_COMMAND = 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"' + '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' ' -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 /** Held PermissionRequest command — computed at module load from PERM_TIMEOUT_MS env. */
// JSON, produced when you tap Approve/Reject) and writes it to stdout for Claude. export const PERM_COMMAND = buildPermCommand(
// Empty/failed (outside web-terminal, or timeout) → Claude shows its own prompt. computeMaxTimeSec(
const PERM_COMMAND = (() => {
'curl -s --max-time 350 -X POST "${WEBTERM_HOOK_URL}/permission"' + const raw = process.env['PERM_TIMEOUT_MS']
' -H "X-Webterm-Session: $WEBTERM_SESSION" -H "Content-Type: application/json" --data-binary @-' 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)) { * Apply (install or remove) web-terminal hooks to a settings.json file.
try { * This is the testable core — the main script entry calls it with real env values.
settings = JSON.parse(readFileSync(SETTINGS, 'utf8')) *
} catch { * @param {Object} opts
console.error(`${SETTINGS} is not valid JSON — aborting (fix it first).`) * @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<string, unknown>} */
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<string, unknown[]>} */
const hooks = /** @type {Record<string, unknown[]>} */ (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<string,unknown>}*/ (g)['hooks']) &&
/** @type {Array<Record<string,unknown>>} */ (
/** @type {Record<string,unknown>} */ (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) process.exit(1)
} }
copyFileSync(SETTINGS, `${SETTINGS}.webterm-bak`)
}
if (typeof settings.hooks !== 'object' || settings.hooks === null) settings.hooks = {} const allEvents = [...FF_EVENTS, 'PermissionRequest']
console.log(
// Strip any existing web-terminal hook groups (makes install idempotent + powers --remove). remove
for (const ev of Object.keys(settings.hooks)) { ? `Removed web-terminal hooks from ${SETTINGS}`
const groups = settings.hooks[ev] : `Installed web-terminal hooks into ${SETTINGS} (events: ${allEvents.join(', ')})`,
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))
),
) )
if (settings.hooks[ev].length === 0) delete settings.hooks[ev]
}
if (!remove) { if (!remove) {
for (const ev of FF_EVENTS) { console.log(`Installed statusLine handler: ${buildStatusLineCommand(SCRIPT_DIR)}`)
if (!Array.isArray(settings.hooks[ev])) settings.hooks[ev] = [] if (hasNtfy) {
settings.hooks[ev].push({ hooks: [{ type: 'command', command: FF_COMMAND }] }) 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.')

186
scripts/statusline.mjs Executable file
View File

@@ -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<string>}
*/
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<string, unknown>} */
const obj = /** @type {Record<string, unknown>} */ (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<string, unknown>} */ (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<string, unknown>} */ (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<string, unknown>} */ (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:<port>/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<void>}
*/
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)
}
})()
}

View File

@@ -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 * 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), * local NIC IPs (M1 — NOT from bindHost; 0.0.0.0 is never a valid Origin),
* and returns a frozen Config object. * and returns a frozen Config object.
*
* v0.7 adds 21 new env vars for the Walk-away Workbench features (A1A5, B1B4).
* 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 os from 'node:os'
import path from 'node:path' 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' import { tmuxAvailable } from './session/tmux.js'
/** USE_TMUX: '1'/'true'/'on' → on, '0'/'false'/'off' → off, else auto-detect tmux. */ /** 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_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 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 ─────────────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────────
/** Parse a non-negative integer env value (0 allowed), or the fallback when unset. */ /** 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 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. /** Parse PROJECT_ROOTS (comma-separated absolute paths); expand a leading '~' to homeDir.
* Empty / unset → [homeDir]. */ * Empty / unset → [homeDir]. */
function parseProjectRoots(raw: string | undefined, homeDir: string): readonly string[] { 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 * Invalid values (port out of range, TTL non-integer, etc.) throw immediately
* so the server never starts in a misconfigured state (fail-fast). * 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 { export function loadConfig(env: EnvLike): Config {
const port = parsePort(env['PORT']) const port = parsePort(env['PORT'])
@@ -218,6 +282,13 @@ export function loadConfig(env: EnvLike): Config {
DEFAULT_PERM_TIMEOUT_MS, 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( const reapIntervalMs = parseNonNegativeInt(
env['REAP_INTERVAL_MS'], env['REAP_INTERVAL_MS'],
'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 projectDirtyCheck = parseBool(env['PROJECT_DIRTY_CHECK'], true)
const editorCmd = env['EDITOR_CMD']?.trim() || DEFAULT_EDITOR_CMD 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, port,
bindHost, bindHost,
shellPath, shellPath,
@@ -266,5 +406,31 @@ export function loadConfig(env: EnvLike): Config {
projectScanTtlMs, projectScanTtlMs,
projectDirtyCheck, projectDirtyCheck,
editorCmd, 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,
})
} }

365
src/http/diff.ts Normal file
View File

@@ -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=<rev>`) 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: `<added>\t<removed>\t<path>`; 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<string, NumstatEntry> {
const map = new Map<string, NumstatEntry>()
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<string, NumstatEntry>,
): 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<string, NumstatEntry>): 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<string, NumstatEntry>): 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<Config, 'diffTimeoutMs' | 'diffMaxBytes' | 'diffMaxFiles'>
}
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<GitOutput> {
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<DiffFile[]> {
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<DiffResult> {
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 }
}

View File

@@ -1,15 +1,20 @@
/** /**
* src/http/hook.ts (H2) — map a Claude Code hook POST to a session status update. * 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 * `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 * 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 * ClaudeStatus the UI can show. The server is still a byte-shuttle for the
* terminal stream — this is an out-of-band side-channel. * 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 { export interface HookEvent {
sessionId: string; sessionId: string;
status: ClaudeStatus; status: ClaudeStatus;
@@ -17,16 +22,56 @@ export interface HookEvent {
} }
/** /**
* @param sessionId value of the `X-Webterm-Session` header * Full event shape returned by parseHookEvent (A4 / B4).
* @param body parsed JSON body of the hook POST (untrusted) *
* @returns a status update, or null if the payload is unusable * `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 (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<string, unknown>; const b = body as Record<string, unknown>;
const event = typeof b['hook_event_name'] === 'string' ? b['hook_event_name'] : ''; 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 notif = typeof b['notification_type'] === 'string' ? b['notification_type'] : '';
const tool = typeof b['tool_name'] === 'string' ? b['tool_name'] : undefined; 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'; status = notif === 'permission_prompt' ? 'waiting' : 'idle';
break; break;
default: default:
// Unreachable: ALLOWED_EVENTS guard above catches unknown names.
return null; return null;
} }
return status === 'waiting' && tool !== undefined // detail: only when waiting + a tool name is present.
? { sessionId, status, detail: tool } const detail = status === 'waiting' && tool !== undefined ? tool : undefined;
: { sessionId, status };
// 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 } : {}),
};
} }

131
src/http/statusline.ts Normal file
View File

@@ -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<string, unknown>;
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<string, unknown>, 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<string, unknown> | null {
if (v === null || typeof v !== 'object' || Array.isArray(v)) return null;
return v as Record<string, unknown>;
}
/** 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 }),
};
}

View File

@@ -5,15 +5,22 @@
* record is the main worktree. Best-effort: a non-repo / git error yields []. * 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 { execFile } from 'node:child_process'
import { promisify } from 'node:util' import { promisify } from 'node:util'
import type { WorktreeInfo } from '../types.js' import type { CreateWorktreeResult, WorktreeInfo } from '../types.js'
const execFileAsync = promisify(execFile) const execFileAsync = promisify(execFile)
const WORKTREE_TIMEOUT_MS = 2000 const WORKTREE_TIMEOUT_MS = 2000
const WORKTREE_MAX_BUFFER = 1024 * 1024 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 * Parse `git worktree list --porcelain`. `currentPath` flags which worktree is
* the requested project (isCurrent). The first record is the main worktree. * the requested project (isCurrent). The first record is the main worktree.
@@ -71,3 +78,174 @@ export async function listWorktrees(repoPath: string): Promise<WorktreeInfo[]> {
return [] 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<string> {
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 ?? <dirname(repo)>/<basename(repo)>-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<string> {
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 `<dir>/.git` exists (file or directory). */
async function hasGitEntry(dir: string): Promise<boolean> {
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<boolean> {
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 → <repo>-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 <branch> -- <dir> [<base>]` via execFile (no shell, `--`
* terminates options). Returns a structured CreateWorktreeResult; never throws.
*/
export async function createWorktree(
repoPath: string,
branch: string,
opts: CreateWorktreeOptions,
): Promise<CreateWorktreeResult> {
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)
}
}

174
src/push/push-service.ts Normal file
View File

@@ -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<void>;
}
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<void> {
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<void> {
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();
}
},
};
}

View File

@@ -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<void>;
}
/** 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<string, unknown>;
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<string, unknown>;
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<void> {
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);
}
},
};
}

View File

@@ -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): * Responsibilities (ARCHITECTURE §3.6): static hosting; WS upgrade gate (wrong
* 1. Express static hosting of public/ (including public/build/ esbuild output). * path → destroy; bad Origin → 401, L3); WS connection (first frame must be
* 2. WebSocketServer in noServer mode (L3) — never auto-attaches its own upgrade. * 'attach', M4 spawn-failure → exit(-1)+close); message routing; close → detach
* 3. HTTP 'upgrade' event (single entry point): * (never kills PTY, invariant #2); SIGINT/SIGTERM → shutdown. M5: every ws.send
* - Wrong path → socket.destroy() * guarded by readyState === WS_OPEN. L5: maxPayload cap. wsPath from config (#8).
* - 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.
* *
* M5: every ws.send guarded by readyState === WS_OPEN. * v0.7 (T-server-wire) adds out-of-band side-channel routes — the terminal
* L3: noServer:true — no double upgrade handling. * stream stays a byte-shuttle. A1 push (/push/*, /hook/decision + C1 hold gate),
* L5: maxPayload: cfg.maxPayloadBytes in WebSocketServer options. * A4 timeline (/…/events), A5 reaper sweepStuck, B1 diff (/projects/diff), B2
* No hardcoded strings: wsPath comes from config (invariant 8). * 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. * Style: immutable data, explicit error handling, no silent swallowing.
*/ */
@@ -27,6 +22,8 @@ import net from 'node:net'
import { URL } from 'node:url' import { URL } from 'node:url'
import path from 'node:path' import path from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { randomUUID } from 'node:crypto'
import { stat } from 'node:fs/promises'
import express from 'express' import express from 'express'
import type { Response } from 'express' import type { Response } from 'express'
@@ -41,9 +38,20 @@ import { parseHookEvent } from './http/hook.js'
import { listSessions } from './http/history.js' import { listSessions } from './http/history.js'
import { buildProjects, buildProjectDetail } from './http/projects.js' import { buildProjects, buildProjectDetail } from './http/projects.js'
import { openInEditor } from './http/editor.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 { createSessionManager } from './session/manager.js'
import { detachWs, writeInput, setClientDims } from './session/session.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' import { WS_OPEN } from './types.js'
// ── defaults ────────────────────────────────────────────────────────────────── // ── 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). // Max chars of a user-controlled string written to a log line (M2 log injection).
const LOG_FIELD_MAX = 200 const LOG_FIELD_MAX = 200
/** The decision JSON a PermissionRequest command hook writes to stdout. */ // Per-IP sliding-window rate limits for the new state-changing routes (SEC-H9,
function permDecision(behavior: 'allow' | 'deny'): unknown { // review #9). Fixed security policy — not user config.
return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior } } } 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<string> = new Set<PermissionMode>([
'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<string, unknown> = { 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<string, unknown>
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<string, number[]>()
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<boolean> {
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<string, unknown>): 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<string, unknown>
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. * both the HTTP server and the session manager.
*/ */
export function startServer(cfg: Config): { close(): Promise<void> } { export function startServer(cfg: Config): { close(): Promise<void> } {
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` // Per-IP rate limiters for the new state-changing routes (SEC-H9).
// is parked here until the client approves/rejects (or it times out). const decisionLimiter = createRateLimiter(DECISION_RATE_MAX, RATE_LIMIT_WINDOW_MS)
const pendingApprovals = new Map<string, { res: Response; timer: ReturnType<typeof setTimeout> }>() 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<typeof setTimeout>
token: string
expiresAt: number
gate: PermissionGate
}
const pendingApprovals = new Map<string, PendingApproval>()
function resolvePending(sessionId: string, decision: unknown): void { function resolvePending(sessionId: string, decision: unknown): void {
const p = pendingApprovals.get(sessionId) const p = pendingApprovals.get(sessionId)
@@ -263,16 +357,25 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
res.status(403).end() res.status(403).end()
return return
} }
const body = (req.body ?? {}) as Record<string, unknown>
const ev = parseHookEvent(req.header('x-webterm-session'), req.body) const ev = parseHookEvent(req.header('x-webterm-session'), req.body)
if (ev === null) { if (ev === null) {
res.status(400).end() res.status(400).end()
return 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() 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. // hook's curl writes our response (the decision JSON) to stdout for Claude.
app.post('/hook/permission', express.json({ limit: '64kb' }), (req, res) => { app.post('/hook/permission', express.json({ limit: '64kb' }), (req, res) => {
if (!isLoopback(req.socket.remoteAddress ?? '')) { if (!isLoopback(req.socket.remoteAddress ?? '')) {
@@ -284,22 +387,205 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
const tool = typeof body['tool_name'] === 'string' ? body['tool_name'] : undefined const tool = typeof body['tool_name'] === 'string' ? body['tool_name'] : undefined
const session = typeof sessionId === 'string' ? manager.get(sessionId) : 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) {
if (sessionId === undefined || session === undefined || session.clients.size === 0) { 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({}) res.json({})
return 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 resolvePending(sessionId, {}) // clear any stale hold for this session
const token = randomUUID()
const expiresAt = Date.now() + cfg.decisionTokenTtlMs
const timer = setTimeout(() => { const timer = setTimeout(() => {
pendingApprovals.delete(sessionId) pendingApprovals.delete(sessionId)
res.json({}) // timeout → fall back to Claude's interactive prompt res.json({}) // timeout → fall back to Claude's interactive prompt
manager.handleHookEvent(sessionId, 'idle') manager.handleHookEvent(sessionId, 'idle')
}, cfg.permTimeoutMs) }, cfg.permTimeoutMs)
pendingApprovals.set(sessionId, { res, timer }) pendingApprovals.set(sessionId, { res, timer, token, expiresAt, gate })
// Show the approve/reject affordance on the client. // A1: push the lock-screen approval (carrying the capability token) to every
manager.handleHookEvent(sessionId, 'waiting', tool, true) // 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<string, unknown>)
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<string, unknown>
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<string, unknown>
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<string, unknown>
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 ─────────────────────────────────────────────────────────── // ── HTTP server ───────────────────────────────────────────────────────────
@@ -313,9 +599,11 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
maxPayload: cfg.maxPayloadBytes, // L5: cap single WS frame size maxPayload: cfg.maxPayloadBytes, // L5: cap single WS frame size
}) })
// ── Idle reaper ─────────────────────────────────────────────────────────── // ── Idle reaper (+ A5 stuck sweep, H4 — no new timer) ─────────────────────
const reapTimer = setInterval(() => { const reapTimer = setInterval(() => {
manager.reapIdle(Date.now()) const now = Date.now()
manager.reapIdle(now)
manager.sweepStuck(now)
}, cfg.reapIntervalMs) }, cfg.reapIntervalMs)
// Don't let this timer prevent the process from exiting. // Don't let this timer prevent the process from exiting.
reapTimer.unref() reapTimer.unref()
@@ -421,6 +709,17 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
// Confirm the attach to the client. // Confirm the attach to the client.
safeSend(ws, serialize({ type: 'attached', sessionId: session.meta.id })) 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 return
} }
@@ -437,8 +736,12 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
// Latest-writer-wins: this device's dims drive the shared PTY size. // Latest-writer-wins: this device's dims drive the shared PTY size.
setClientDims(session, ws, msg.cols, msg.rows) setClientDims(session, ws, msg.cols, msg.rows)
} else if (msg.type === 'approve') { } else if (msg.type === 'approve') {
// H3: resolve the held PermissionRequest with allow. // H3/B4: resolve the held PermissionRequest with allow, carrying the
resolvePending(boundSessionId, permDecision('allow')) // 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) manager.handleHookEvent(boundSessionId, 'working', undefined, false)
} else if (msg.type === 'reject') { } else if (msg.type === 'reject') {
resolvePending(boundSessionId, permDecision('deny')) resolvePending(boundSessionId, permDecision('deny'))

View File

@@ -30,13 +30,18 @@ import type {
Config, Config,
Dims, Dims,
LiveSessionInfo, LiveSessionInfo,
NotifyService,
PermissionGate,
ServerMessage,
Session, Session,
SessionManager, SessionManager,
StatusTelemetry,
WebSocketLike, WebSocketLike,
} from '../types.js'; } from '../types.js';
import { WS_OPEN } from '../types.js'; import { WS_OPEN } from '../types.js';
import { serialize } from '../protocol.js'; import { serialize } from '../protocol.js';
import { createSession, attachWs, broadcast, kill } from './session.js'; import { createSession, attachWs, broadcast, kill } from './session.js';
import { appendEvent, makeTimelineEvent } from './timeline.js';
import { hasSession, tmuxName } from './tmux.js'; import { hasSession, tmuxName } from './tmux.js';
/** /**
@@ -57,8 +62,16 @@ function sendIfOpen(ws: WebSocketLike | null, msg: Parameters<typeof serialize>[
* (add, delete) rather than in-place modification of the table structure. * (add, delete) rather than in-place modification of the table structure.
* Individual Session objects do carry mutable fields (attachedWs, detachedAt, * Individual Session objects do carry mutable fields (attachedWs, detachedAt,
* lastOutputAt, exitedAt, exitCode) by design — those are runtime handles. * 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<string, Session> = new Map(); let sessions: Map<string, Session> = new Map();
/** /**
@@ -117,6 +130,14 @@ export function createSessionManager(cfg: Config): SessionManager {
// ── Case 2: hit a live session → JOIN it (multi-device sharing) ─────────── // ── Case 2: hit a live session → JOIN it (multi-device sharing) ───────────
if (existing !== undefined && existing.exitedAt === null) { if (existing !== undefined && existing.exitedAt === null) {
attachWs(existing, ws); 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; return existing;
} }
@@ -168,6 +189,7 @@ export function createSessionManager(cfg: Config): SessionManager {
cwd: s.cwd, cwd: s.cwd,
cols: s.pty.cols, cols: s.pty.cols,
rows: s.pty.rows, rows: s.pty.rows,
telemetry: s.telemetry, // B2: latest telemetry for the thumbnail wall
})) }))
.sort((a, b) => b.createdAt - a.createdAt); .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 * H2/H3: a Claude Code hook reported activity for `sessionId`. Update the
* a `status` frame to the attached ws (no-op if the session is gone/detached). * 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( function handleHookEvent(
sessionId: string, sessionId: string,
status: ClaudeStatus, status: ClaudeStatus,
detail?: string, detail?: string,
pending?: boolean, pending?: boolean,
gate?: PermissionGate,
eventClass?: string,
toolName?: string,
): void { ): void {
const session = sessions.get(sessionId); const session = sessions.get(sessionId);
if (session === undefined) return; if (session === undefined) return;
session.claudeStatus = status; session.claudeStatus = status;
const msg: { type: 'status'; status: ClaudeStatus; detail?: string; pending?: boolean } = {
type: 'status', // A4: derive a bounded, sanitized timeline event from the raw hook name.
status, // 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<ServerMessage, { type: 'status' }> = { type: 'status', status };
if (detail !== undefined) msg.detail = detail; if (detail !== undefined) msg.detail = detail;
if (pending) msg.pending = true; if (pending) msg.pending = true;
if (gate !== undefined) msg.gate = gate;
broadcast(session, msg); 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). * Reclaim detached sessions whose liveness window has expired (M3).
* *
@@ -259,5 +331,15 @@ export function createSessionManager(cfg: Config): SessionManager {
sessions = new Map(); sessions = new Map();
} }
return { handleAttach, get, list, killById, handleHookEvent, reapIdle, shutdown }; return {
handleAttach,
get,
list,
killById,
handleHookEvent,
handleStatusLine,
sweepStuck,
reapIdle,
shutdown,
};
} }

View File

@@ -32,6 +32,7 @@ import type {
Session, Session,
SessionMeta, SessionMeta,
ServerMessage, ServerMessage,
TimelineEvent,
WebSocketLike, WebSocketLike,
} from '../types.js'; } from '../types.js';
import { WS_OPEN } from '../types.js'; import { WS_OPEN } from '../types.js';
@@ -95,6 +96,9 @@ export function createSession(
...process.env, ...process.env,
WEBTERM_SESSION: id, WEBTERM_SESSION: id,
WEBTERM_HOOK_URL: `http://127.0.0.1:${cfg.port}/hook`, 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, cwd: cwd ?? null,
tmuxName: tName, tmuxName: tName,
pty, 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. // onData: persist to scrollback, refresh liveness, broadcast to all clients.
pty.onData((chunk) => { pty.onData((chunk) => {
session.buffer.append(chunk); session.buffer.append(chunk);
session.lastOutputAt = Date.now(); 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 }); broadcast(session, { type: 'output', data: chunk });
}); });

143
src/session/timeline.ts Normal file
View File

@@ -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<Record<string, TimelineClass>> = {
PreToolUse: 'tool',
PostToolUse: 'tool',
PermissionRequest: 'waiting',
Notification: 'waiting',
Stop: 'done',
SessionEnd: 'done',
UserPromptSubmit: 'user',
};
// ── sanitizeField ─────────────────────────────────────────────────────────────
/**
* Strip ASCII control characters (0x000x1f) 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;
}

View File

@@ -40,6 +40,35 @@ export interface Config {
readonly projectScanTtlMs: number; // PROJECT_SCAN_TTL, default 10000 (discovery cache) readonly projectScanTtlMs: number; // PROJECT_SCAN_TTL, default 10000 (discovery cache)
readonly projectDirtyCheck: boolean; // PROJECT_DIRTY_CHECK, default true (git status per repo) readonly projectDirtyCheck: boolean; // PROJECT_DIRTY_CHECK, default true (git status per repo)
readonly editorCmd: string; // EDITOR_CMD, default 'code' — POST /open-in-editor launches `<editorCmd> <repoPath>` on the host readonly editorCmd: string; // EDITOR_CMD, default 'code' — POST /open-in-editor launches `<editorCmd> <repoPath>` 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.*`. */ /** process.env is structurally assignable to this; keeps the file free of `NodeJS.*`. */
@@ -51,26 +80,43 @@ export type EnvLike = Readonly<Record<string, string | undefined>>;
/* ─────────────────────── protocol (§3.2) ─────────────────────── */ /* ─────────────────────── protocol (§3.2) ─────────────────────── */
/** client → server. approve/reject (H3) resolve a held PermissionRequest. /** 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 = export type ClientMessage =
| { type: 'attach'; sessionId: string | null; cwd?: string } | { type: 'attach'; sessionId: string | null; cwd?: string }
| { type: 'input'; data: string } | { type: 'input'; data: string }
| { type: 'resize'; cols: number; rows: number } | { type: 'resize'; cols: number; rows: number }
| { type: 'approve' } | { type: 'approve'; mode?: PermissionMode }
| { type: 'reject' }; | { type: 'reject' };
/** Claude Code activity, derived from hooks (H2). 'unknown' = no hook signal yet. */ /** Claude Code activity, derived from hooks (H2). 'unknown' = no hook signal yet.
export type ClaudeStatus = 'working' | 'waiting' | 'idle' | 'unknown'; * '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). /** server → client. exit.code = shell code; -1 when spawn never succeeded (M4).
* exit.reason optional normally, REQUIRED on spawn failure / abnormal exit. * exit.reason optional normally, REQUIRED on spawn failure / abnormal exit.
* status (H2/H3) = Claude Code activity; `pending` true when a tool approval * 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 = export type ServerMessage =
| { type: 'attached'; sessionId: string } | { type: 'attached'; sessionId: string }
| { type: 'output'; data: string } | { type: 'output'; data: string }
| { type: 'exit'; code: number; reason?: 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). */ /** parseClientMessage result — never throws; errors flow here (§5.3). */
export type ParseResult = export type ParseResult =
@@ -172,6 +218,15 @@ export interface Session {
cwd: string | null; cwd: string | null;
/** tmux session name (H1) when running under tmux, else null. */ /** tmux session name (H1) when running under tmux, else null. */
readonly tmuxName: string | 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; readonly pty: IPty;
} }
@@ -196,6 +251,7 @@ export interface LiveSessionInfo {
cwd: string | null; cwd: string | null;
cols: number; // current PTY size (for the manage page) cols: number; // current PTY size (for the manage page)
rows: number; rows: number;
telemetry?: StatusTelemetry | null; // B2: latest telemetry for the thumbnail wall
} }
/* ───────────────── project manager (v0.6, §4.3 FEATURE doc) ──────────────── */ /* ───────────────── 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. /** Kill a session by id (manage page): close its clients, kill the PTY, drop it.
* Returns true if a session was found and killed. */ * Returns true if a session was found and killed. */
killById(id: string): boolean; 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( handleHookEvent(
sessionId: string, sessionId: string,
status: ClaudeStatus, status: ClaudeStatus,
detail?: string, detail?: string,
pending?: boolean, pending?: boolean,
gate?: PermissionGate,
eventClass?: string,
toolName?: string,
): void; ): 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. */ /** reclaim when now - max(detachedAt, lastOutputAt) > idleTtlMs (M3). Returns count. */
reapIdle(now: number): number; reapIdle(now: number): number;
shutdown(): void; shutdown(): void;
} }
// impl anchor [src/session/manager.ts]: // 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<void>;
}
/* ── 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) ──────────────────── */ /* ─────────────────────── frontend (§5/§6.3) ──────────────────── */

View File

@@ -445,3 +445,288 @@ describe('loadConfig — project discovery (v0.6)', () => {
expect(loadConfig({ EDITOR_CMD: ' ' }).editorCmd).toBe('code') 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 <homeDir>/.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')
})
})

483
test/diff.test.ts Normal file
View File

@@ -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> = {}): 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> = {}): 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 <script> content as plain text — not injected as HTML (SEC-H4)', () => {
const xssLine = '<script>alert(1)</script>'
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 <script> element should be injected
expect(el.querySelectorAll('script').length).toBe(0)
})
it('renders & as literal text (not HTML entity expanded) (SEC-H4)', () => {
const file = makeFile({
hunks: [makeHunk('@@ -1 +1 @@', [
makeLine('context', ' a & b && c'),
])],
})
const el = renderDiffFile(file)
expect(el.textContent).toContain('a & b && c')
// innerHTML should NOT contain unescaped &
expect(el.innerHTML).not.toContain(' a & b ')
})
it('renders ANSI escape sequences as plain text (SEC-H4)', () => {
const ansi = '\x1b[32m+green\x1b[0m'
const file = makeFile({
hunks: [makeHunk('@@ -1 +1 @@', [makeLine('added', ansi)])],
})
const el = renderDiffFile(file)
expect(el.textContent).toContain(ansi)
})
it('renders a binary file indicator (no hunks)', () => {
const file = makeFile({ binary: true, hunks: [] })
const el = renderDiffFile(file)
expect(el.textContent?.toLowerCase()).toContain('binary')
})
it('renders status for renamed file (shows old + new path)', () => {
const file = makeFile({
oldPath: 'src/old.ts',
newPath: 'src/new.ts',
status: 'renamed',
hunks: [],
})
const el = renderDiffFile(file)
const text = el.textContent ?? ''
expect(text).toContain('src/old.ts')
expect(text).toContain('src/new.ts')
})
it('renders deleted file with status indicator', () => {
const file = makeFile({ status: 'deleted', hunks: [] })
const el = renderDiffFile(file)
expect(el.textContent?.toLowerCase()).toContain('deleted')
})
it('renders added (new) file with status indicator', () => {
const file = makeFile({ status: 'added', hunks: [] })
const el = renderDiffFile(file)
expect(el.textContent?.toLowerCase()).toContain('added')
})
it('returns an HTMLElement (not null)', () => {
expect(renderDiffFile(makeFile())).toBeInstanceOf(HTMLElement)
})
})
// ── renderDiff ────────────────────────────────────────────────────────────────
describe('renderDiff', () => {
it('renders each file from the result', () => {
const result = makeResult({
files: [
makeFile({ newPath: 'a.ts' }),
makeFile({ newPath: 'b.ts' }),
],
})
const el = renderDiff(result)
expect(el.textContent).toContain('a.ts')
expect(el.textContent).toContain('b.ts')
})
it('shows empty state message when no files changed', () => {
const result = makeResult({ files: [] })
const el = renderDiff(result)
// Should show some empty-state text
const text = el.textContent?.toLowerCase() ?? ''
expect(text).toMatch(/no (changes|diff|files)/i)
})
it('shows truncated warning when result.truncated is true', () => {
const result = makeResult({ truncated: true })
const el = renderDiff(result)
const text = el.textContent?.toLowerCase() ?? ''
expect(text).toMatch(/truncated|too large|large/i)
})
it('does NOT show truncated warning when result.truncated is false', () => {
const result = makeResult({ truncated: false })
const el = renderDiff(result)
const text = el.textContent?.toLowerCase() ?? ''
expect(text).not.toMatch(/truncated/i)
})
it('renders <script> in diff content as text not HTML (SEC-H4)', () => {
const result = makeResult({
files: [makeFile({
hunks: [makeHunk('@@ @@', [makeLine('added', '<script>evil()</script>')])],
})],
})
const el = renderDiff(result)
expect(el.textContent).toContain('<script>evil()</script>')
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()
})
})

View File

@@ -1,42 +1,54 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { parseHookEvent } from '../src/http/hook.js' import { parseHookEvent } from '../src/http/hook.js'
import type { HookEventFull } from '../src/http/hook.js'
const SID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479' const SID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
describe('parseHookEvent', () => { // ── Existing status / detail behaviour (must not regress) ───────────────────
it('maps tool events to "working"', () => { // 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']) { for (const e of ['PreToolUse', 'PostToolUse', 'UserPromptSubmit']) {
expect(parseHookEvent(SID, { hook_event_name: e })).toEqual({ expect(parseHookEvent(SID, { hook_event_name: e })).toEqual(
sessionId: SID, expect.objectContaining({ sessionId: SID, status: 'working' }),
status: 'working', )
})
} }
}) })
it('maps PermissionRequest to "waiting" with the tool name as detail', () => { it('maps PermissionRequest to "waiting" with tool_name as detail', () => {
expect(parseHookEvent(SID, { hook_event_name: 'PermissionRequest', tool_name: 'Bash' })).toEqual( expect(
{ sessionId: SID, status: 'waiting', detail: 'Bash' }, 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( expect(
parseHookEvent(SID, { hook_event_name: 'Notification', notification_type: 'permission_prompt' }), parseHookEvent(SID, {
).toEqual({ sessionId: SID, status: 'waiting' }) hook_event_name: 'Notification',
notification_type: 'permission_prompt',
}),
).toEqual(expect.objectContaining({ sessionId: SID, status: 'waiting' }))
expect( expect(
parseHookEvent(SID, { hook_event_name: 'Notification', notification_type: 'idle_prompt' }), parseHookEvent(SID, {
).toEqual({ sessionId: SID, status: 'idle' }) hook_event_name: 'Notification',
notification_type: 'idle_prompt',
}),
).toEqual(expect.objectContaining({ sessionId: SID, status: 'idle' }))
}) })
it('maps Stop / SessionEnd to "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: 'Stop' })).toEqual(
expect(parseHookEvent(SID, { hook_event_name: 'SessionEnd' })).toEqual({ expect.objectContaining({ sessionId: SID, status: 'idle' }),
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(undefined, { hook_event_name: 'Stop' })).toBeNull()
expect(parseHookEvent('', { hook_event_name: 'Stop' })).toBeNull() expect(parseHookEvent('', { hook_event_name: 'Stop' })).toBeNull()
expect(parseHookEvent(SID, null)).toBeNull() expect(parseHookEvent(SID, null)).toBeNull()
@@ -45,3 +57,161 @@ describe('parseHookEvent', () => {
expect(parseHookEvent(SID, {})).toBeNull() 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()
}
})
})

348
test/http/diff.test.ts Normal file
View File

@@ -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; <script> stays text).
* 2. getDiff against a REAL throwaway git repo in os.tmpdir (AC-B1.1, L3).
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import os from 'node:os'
import path from 'node:path'
import fs from 'node:fs/promises'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import {
parseNumstat,
parseUnifiedDiff,
getDiff,
type NumstatEntry,
type GetDiffOptions,
} from '../../src/http/diff.js'
const execFileAsync = promisify(execFile)
const LIMITS: GetDiffOptions['cfg'] = {
diffTimeoutMs: 5000,
diffMaxBytes: 2 * 1024 * 1024,
diffMaxFiles: 300,
}
// ── parseNumstat ──────────────────────────────────────────────────────────────
describe('parseNumstat', () => {
it('parses a basic added/removed line', () => {
const m = parseNumstat('2\t1\tfoo.txt\n')
expect(m.get('foo.txt')).toEqual<NumstatEntry>({ added: 2, removed: 1, binary: false })
})
it('flags binary files (-\\t-) with zero counts', () => {
const m = parseNumstat('-\t-\timg.png\n')
expect(m.get('img.png')).toEqual<NumstatEntry>({ added: 0, removed: 0, binary: true })
})
it('keys a plain-arrow rename under both old and new paths', () => {
const m = parseNumstat('1\t1\told.txt => new.txt\n')
const entry = { added: 1, removed: 1, binary: false }
expect(m.get('old.txt')).toEqual(entry)
expect(m.get('new.txt')).toEqual(entry)
})
it('expands a braced rename path (common prefix/suffix)', () => {
const m = parseNumstat('3\t4\tsrc/{a => b}/file.js\n')
expect(m.has('src/a/file.js')).toBe(true)
expect(m.has('src/b/file.js')).toBe(true)
expect(m.get('src/b/file.js')).toEqual({ added: 3, removed: 4, binary: false })
})
it('collapses double slashes when a brace side is empty', () => {
const m = parseNumstat('1\t0\tsrc/{ => sub}/file.txt\n')
expect(m.has('src/file.txt')).toBe(true)
expect(m.has('src/sub/file.txt')).toBe(true)
})
it('returns an empty map for empty input', () => {
expect(parseNumstat('').size).toBe(0)
})
it('skips malformed lines without throwing', () => {
const m = parseNumstat('garbage\n2\t1\tok.txt\n\n')
expect(m.size).toBe(1)
expect(m.get('ok.txt')).toEqual({ added: 2, removed: 1, binary: false })
})
})
// ── parseUnifiedDiff ─────────────────────────────────────────────────────────
const MODIFIED = [
'diff --git a/foo.txt b/foo.txt',
'index e69de29..d95f3ad 100644',
'--- a/foo.txt',
'+++ b/foo.txt',
'@@ -1,2 +1,3 @@',
' keep',
'-old',
'+new',
'+added',
'',
].join('\n')
describe('parseUnifiedDiff', () => {
it('returns [] for empty / non-diff input', () => {
expect(parseUnifiedDiff('')).toEqual([])
expect(parseUnifiedDiff('not a diff at all')).toEqual([])
})
it('parses a modified file into one hunk with typed lines', () => {
const [file] = parseUnifiedDiff(MODIFIED)
expect(file).toBeDefined()
expect(file?.oldPath).toBe('foo.txt')
expect(file?.newPath).toBe('foo.txt')
expect(file?.status).toBe('modified')
expect(file?.binary).toBe(false)
expect(file?.hunks).toHaveLength(1)
expect(file?.hunks[0]?.header).toBe('@@ -1,2 +1,3 @@')
expect(file?.hunks[0]?.lines).toEqual([
{ kind: 'context', text: 'keep' },
{ kind: 'removed', text: 'old' },
{ kind: 'added', text: 'new' },
{ kind: 'added', text: 'added' },
])
})
it('counts +/- from the hunk when no numstat is supplied', () => {
const [file] = parseUnifiedDiff(MODIFIED)
expect(file?.added).toBe(2)
expect(file?.removed).toBe(1)
})
it('prefers numstat counts over hunk counts', () => {
const numstat = parseNumstat('99\t88\tfoo.txt\n')
const [file] = parseUnifiedDiff(MODIFIED, numstat)
expect(file?.added).toBe(99)
expect(file?.removed).toBe(88)
})
it('detects an added file via /dev/null source', () => {
const patch = [
'diff --git a/new.txt b/new.txt',
'new file mode 100644',
'index 0000000..1234567',
'--- /dev/null',
'+++ b/new.txt',
'@@ -0,0 +1,2 @@',
'+line one',
'+line two',
'',
].join('\n')
const [file] = parseUnifiedDiff(patch)
expect(file?.status).toBe('added')
expect(file?.newPath).toBe('new.txt')
expect(file?.added).toBe(2)
expect(file?.removed).toBe(0)
})
it('detects a deleted file via /dev/null target', () => {
const patch = [
'diff --git a/del.txt b/del.txt',
'deleted file mode 100644',
'index 1234567..0000000',
'--- a/del.txt',
'+++ /dev/null',
'@@ -1,2 +0,0 @@',
'-line one',
'-line two',
'',
].join('\n')
const [file] = parseUnifiedDiff(patch)
expect(file?.status).toBe('deleted')
expect(file?.oldPath).toBe('del.txt')
expect(file?.removed).toBe(2)
})
it('detects a rename and maps numstat by the new path', () => {
const patch = [
'diff --git a/old/name.txt b/new/name.txt',
'similarity index 95%',
'rename from old/name.txt',
'rename to new/name.txt',
'index 111..222 100644',
'--- a/old/name.txt',
'+++ b/new/name.txt',
'@@ -1 +1 @@',
'-hello',
'+hello world',
'',
].join('\n')
const numstat = parseNumstat('1\t1\t{old => new}/name.txt\n')
const [file] = parseUnifiedDiff(patch, numstat)
expect(file?.status).toBe('renamed')
expect(file?.oldPath).toBe('old/name.txt')
expect(file?.newPath).toBe('new/name.txt')
expect(file?.added).toBe(1)
expect(file?.removed).toBe(1)
})
it('detects a binary file (Binary files … differ + numstat -\\t-)', () => {
const patch = [
'diff --git a/img.png b/img.png',
'index 111..222 100644',
'Binary files a/img.png and b/img.png differ',
'',
].join('\n')
const numstat = parseNumstat('-\t-\timg.png\n')
const [file] = parseUnifiedDiff(patch, numstat)
expect(file?.status).toBe('binary')
expect(file?.binary).toBe(true)
expect(file?.hunks).toEqual([])
expect(file?.added).toBe(0)
expect(file?.removed).toBe(0)
})
it('parses multiple hunks and a no-newline meta marker', () => {
const patch = [
'diff --git a/m.txt b/m.txt',
'index 111..222 100644',
'--- a/m.txt',
'+++ b/m.txt',
'@@ -1,2 +1,2 @@',
' a',
'-b',
'+B',
'@@ -10,2 +10,3 @@',
' x',
'+y',
'\\ No newline at end of file',
'',
].join('\n')
const [file] = parseUnifiedDiff(patch)
expect(file?.hunks).toHaveLength(2)
const meta = file?.hunks[1]?.lines.find((l) => l.kind === 'meta')
expect(meta?.text).toContain('No newline')
})
it('classifies an unrecognised hunk line as context (never throws)', () => {
const patch = [
'diff --git a/x.txt b/x.txt',
'--- a/x.txt',
'+++ b/x.txt',
'@@ -1 +1 @@',
'?weird line',
'',
].join('\n')
const [file] = parseUnifiedDiff(patch)
expect(file?.hunks[0]?.lines[0]).toEqual({ kind: 'context', text: '?weird line' })
})
it('preserves <script>/ANSI/backtick content verbatim as text (AC-B1.4)', () => {
const payload = '<script>alert(1)</script> `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<void> {
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)
})
})

View File

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

View File

@@ -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<string> {
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 <repo>-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 })
})
})

View File

@@ -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<void> => {
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<number> {
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<void> }[] = []
const tmpFiles: string[] = []
async function spawnServer(
overrides: Record<string, string | undefined> = {},
): 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<void>((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<void> {
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<string, unknown>) => boolean): Promise<Record<string, unknown>> {
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<string, unknown>
try {
m = JSON.parse(raw.toString('utf8')) as Record<string, unknown>
} catch {
return
}
if (pred(m)) {
clearTimeout(t)
ws.off('message', onMsg)
resolve(m)
}
}
ws.on('message', onMsg)
})
}
const isAttached = (m: Record<string, unknown>): 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<void>((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<void>((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<void>((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')
})
})

View File

@@ -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<number> {
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<void> }[] = []
async function spawnServer(
overrides: Record<string, string | undefined> = {},
): 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<void>((r) => setTimeout(r, 80))
return { port, origin: `http://127.0.0.1:${port}` }
}
function waitForOpen(ws: WebSocket): Promise<void> {
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<string, unknown>) => boolean): Promise<Record<string, unknown>> {
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<string, unknown>
try {
m = JSON.parse(raw.toString('utf8')) as Record<string, unknown>
} catch {
return
}
if (pred(m)) {
clearTimeout(t)
ws.off('message', onMsg)
resolve(m)
}
}
ws.on('message', onMsg)
})
}
const isAttached = (m: Record<string, unknown>): boolean => m['type'] === 'attached'
afterEach(async () => {
while (handles.length > 0) await handles.pop()?.close()
await new Promise<void>((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<string, unknown> }
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 })
})
})

View File

@@ -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<number> {
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<void> }[] = []
const tmpDirs: string[] = []
async function spawnServer(
overrides: Record<string, string | undefined> = {},
): 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<void>((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<string> {
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<void>((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<script>x</script>\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('<script>x</script>')
})
})

View File

@@ -13,7 +13,14 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'; 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 { WS_OPEN } from '../src/types.js';
import { createMockPty, type MockIPty } from './helpers/mock-pty.js'; import { createMockPty, type MockIPty } from './helpers/mock-pty.js';
@@ -53,10 +60,53 @@ const CFG: Config = {
previewBytes: 24 * 1024, previewBytes: 24 * 1024,
useTmux: false, useTmux: false,
allowedOrigins: [], 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 }; 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 { interface MockWs extends WebSocketLike {
readyState: number; readyState: number;
sent: string[]; sent: string[];
@@ -646,3 +696,356 @@ describe('onExit removes session from table when ws attached at exit time (L2)',
expect(session.exitedAt).not.toBeNull(); 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();
});
});

508
test/push.test.ts Normal file
View File

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

View File

@@ -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> = {}): 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<string, number> = {}): 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

View File

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

552
test/quick-reply.test.ts Normal file
View File

@@ -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> = {}): 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 <script> appear as plain text — not injected as HTML (AC-A3.4 / SEC-L3)', () => {
localStorage.setItem(
PALETTE_KEY,
JSON.stringify([
makeChip({ id: 'xss', label: '<script>alert(1)</script>', text: 'safe', appendEnter: false }),
]),
)
const container = document.createElement('div')
const onSend = vi.fn()
const handle = mountQuickReply(container, { onSend })
// No <script> element should be injected into the DOM
expect(container.querySelectorAll('script').length).toBe(0)
// The label text should appear as inert text
const xssBtn = Array.from(container.querySelectorAll('button.qr-chip')).find(
(b) => b.textContent === '<script>alert(1)</script>',
)
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()
})
})

View File

@@ -53,6 +53,12 @@ const CFG: Config = {
previewBytes: 24 * 1024, previewBytes: 24 * 1024,
useTmux: false, useTmux: false,
allowedOrigins: [], 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 }; const DIMS: Dims = { cols: 80, rows: 24 };
@@ -407,3 +413,129 @@ describe('kill', () => {
expect((s.pty as MockIPty).killed).toBe(true); 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<string, string> };
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<string, string> };
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<string, string> };
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' });
});
});

View File

@@ -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> = {}): 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 (0x000x1f)', () => {
// 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);
});
}
});

436
test/setup-hooks.test.ts Normal file
View File

@@ -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 <actual-token>" 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)
}
})
})

262
test/statusline.test.ts Normal file
View File

@@ -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<string, string>)['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<string, string>)['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()
})
})

190
test/sw-push.test.ts Normal file
View File

@@ -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<string, unknown>): Notification {
return { data } as unknown as Notification
}
// ─────────────── buildPushNotification ────────────────────────────────────
describe('buildPushNotification', () => {
it('needs-input: requireInteraction=true, Allow+Deny actions, tag=sessionId', () => {
const payload: PushPayload = {
sessionId: 'sess-001',
toolName: 'Bash',
detail: 'rm -rf /tmp/cache',
token: 'tok-aaa',
cls: 'needs-input',
}
const { title, options } = buildPushNotification(payload)
expect(title).toBeTruthy()
expect(typeof title).toBe('string')
expect(options.tag).toBe('sess-001') // SEC-M6
expect(options.requireInteraction).toBe(true)
const actions: Array<{ action: string; title: string }> = options.actions ?? []
expect(actions.length).toBeGreaterThanOrEqual(2)
expect(actions.some((a) => a.action === 'allow')).toBe(true)
expect(actions.some((a) => a.action === 'deny')).toBe(true)
expect(options.data).toMatchObject({
sessionId: 'sess-001',
token: 'tok-aaa',
cls: 'needs-input',
})
})
it('needs-input without detail falls back to toolName in body', () => {
const payload: PushPayload = {
sessionId: 's',
toolName: 'Edit',
token: 'tok-bbb',
cls: 'needs-input',
}
const { options } = buildPushNotification(payload)
expect(options.body ?? '').toContain('Edit')
})
it('done: no requireInteraction, no actions, tag=sessionId', () => {
const payload: PushPayload = { sessionId: 'sess-002', cls: 'done' }
const { title, options } = buildPushNotification(payload)
expect(title).toBeTruthy()
expect(options.tag).toBe('sess-002') // SEC-M6
expect(options.requireInteraction).toBeFalsy()
expect((options.actions ?? []).length).toBe(0)
expect(options.data).toMatchObject({ sessionId: 'sess-002', cls: 'done' })
})
it('done: body includes detail when provided', () => {
const payload: PushPayload = {
sessionId: 'sess-003',
detail: 'Build succeeded',
cls: 'done',
}
const { options } = buildPushNotification(payload)
expect(options.body).toContain('Build succeeded')
})
it('stuck: no requireInteraction, no actions, tag=sessionId', () => {
const payload: PushPayload = { sessionId: 'sess-004', cls: 'stuck' }
const { title, options } = buildPushNotification(payload)
expect(title).toBeTruthy()
expect(options.tag).toBe('sess-004') // SEC-M6
expect(options.requireInteraction).toBeFalsy()
expect((options.actions ?? []).length).toBe(0)
expect(options.data).toMatchObject({ sessionId: 'sess-004', cls: 'stuck' })
})
it('no detail and no toolName → body is undefined or empty', () => {
const payload: PushPayload = { sessionId: 'sess-005', cls: 'done' }
const { options } = buildPushNotification(payload)
// body should be undefined or an empty string — not a thrown error
expect(() => buildPushNotification(payload)).not.toThrow()
const body = options.body
expect(body === undefined || body === '').toBe(true)
})
})
// ─────────────── resolveNotificationClick ─────────────────────────────────
describe('resolveNotificationClick', () => {
it('allow action → decision with decision=allow and capability token (SEC-C1)', () => {
const notification = fakeNotification({
sessionId: 'sess-10',
token: 'tok-xyz',
cls: 'needs-input',
})
const result = resolveNotificationClick(notification, 'allow')
expect(result.kind).toBe('decision')
const body = JSON.parse(result.body) as Record<string, unknown>
expect(body.sessionId).toBe('sess-10')
expect(body.decision).toBe('allow')
expect(body.token).toBe('tok-xyz') // SEC-C1: token forwarded
})
it('deny action → decision with decision=deny', () => {
const notification = fakeNotification({
sessionId: 'sess-11',
token: 'tok-abc',
cls: 'needs-input',
})
const result = resolveNotificationClick(notification, 'deny')
expect(result.kind).toBe('decision')
const body = JSON.parse(result.body) as Record<string, unknown>
expect(body.sessionId).toBe('sess-11')
expect(body.decision).toBe('deny')
expect(body.token).toBe('tok-abc')
})
it('empty action string (body click) → focus with session URL', () => {
const notification = fakeNotification({ sessionId: 'sess-12' })
const result = resolveNotificationClick(notification, '')
expect(result.kind).toBe('focus')
expect(result.url).toBe('/?session=sess-12')
})
it('undefined action (body click) → focus with session URL', () => {
const notification = fakeNotification({ sessionId: 'sess-13' })
const result = resolveNotificationClick(notification, undefined)
expect(result.kind).toBe('focus')
expect(result.url).toBe('/?session=sess-13')
})
it('null action → focus (treated as default body click)', () => {
const notification = fakeNotification({ sessionId: 'sess-14' })
const result = resolveNotificationClick(notification, null)
expect(result.kind).toBe('focus')
expect(result.url).toContain('sess-14')
})
it('token is undefined in body when not in notification data (done notification)', () => {
// done/stuck notifications have no token in data; test edge: action='allow' still
// sends a decision body but token field is absent (server should reject gracefully)
const notification = fakeNotification({ sessionId: 'sess-15', cls: 'done' })
const result = resolveNotificationClick(notification, 'allow')
expect(result.kind).toBe('decision')
const body = JSON.parse(result.body) as Record<string, unknown>
expect(body.token).toBeUndefined()
})
it('sessionId is URL-encoded in focus URL (special characters safe)', () => {
const notification = fakeNotification({ sessionId: 'sess a+b' })
const result = resolveNotificationClick(notification, '')
expect(result.kind).toBe('focus')
// encodeURIComponent encodes spaces and + signs
expect(result.url).not.toContain(' ')
})
it('missing data on notification → focus URL with empty session gracefully', () => {
// notification.data is null/undefined
const notification = { data: null } as unknown as Notification
expect(() => resolveNotificationClick(notification, '')).not.toThrow()
})
})

View File

@@ -8,7 +8,7 @@
* (Phase 1#5), and the v0.6 openProject / home segmented-control wiring (P6). * (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 ────────────────────────────────────────────────────── // ── Mock TerminalSession ──────────────────────────────────────────────────────
let constructed = 0 let constructed = 0
@@ -19,6 +19,11 @@ class FakeTerminalSession {
claudeStatus = 'unknown' claudeStatus = 'unknown'
cwd: string | null = null cwd: string | null = null
pendingApproval = false 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). */ /** Captured so tests can assert initialInput ends with \r (Finding 1). */
initialInput: string | undefined = undefined initialInput: string | undefined = undefined
connect = vi.fn() connect = vi.fn()
@@ -39,6 +44,7 @@ class FakeTerminalSession {
onStatus?: (s: string) => void onStatus?: (s: string) => void
onActivity?: () => void onActivity?: () => void
onTitle?: (t: string) => void onTitle?: (t: string) => void
onTelemetry?: (t: unknown) => void
} }
static instances: FakeTerminalSession[] = [] static instances: FakeTerminalSession[] = []
constructor(opts: { constructor(opts: {
@@ -48,6 +54,7 @@ class FakeTerminalSession {
onStatus?: (s: string) => void onStatus?: (s: string) => void
onActivity?: () => void onActivity?: () => void
onTitle?: (t: string) => void onTitle?: (t: string) => void
onTelemetry?: (t: unknown) => void
[key: string]: unknown [key: string]: unknown
}) { }) {
constructed += 1 constructed += 1
@@ -61,6 +68,37 @@ class FakeTerminalSession {
vi.mock('../public/terminal-session.js', () => ({ TerminalSession: 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) ──────────────────────────────────── // ── Mock the launcher (records visibility) ────────────────────────────────────
const launcherVisible = { value: false } const launcherVisible = { value: false }
const mountLauncher = vi.fn(() => ({ const mountLauncher = vi.fn(() => ({
@@ -88,10 +126,21 @@ const { TabApp } = await import('../public/tabs.js')
function makeHosts(): { paneHost: HTMLElement; tabBar: HTMLElement } { function makeHosts(): { paneHost: HTMLElement; tabBar: HTMLElement } {
const paneHost = document.createElement('div') const paneHost = document.createElement('div')
const tabBar = 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 } 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(() => { beforeEach(() => {
constructed = 0 constructed = 0
FakeTerminalSession.instances = [] FakeTerminalSession.instances = []
@@ -99,6 +148,23 @@ beforeEach(() => {
projectsVisible.value = false projectsVisible.value = false
document.body.replaceChildren() document.body.replaceChildren()
localStorage.clear() 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', () => { 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 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()
})
})

View File

@@ -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> = {}): 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 <script> model name as plain text — not injected into DOM (SEC-H5)', () => {
const c = makeContainer()
const xss = '<script>alert(1)</script>'
renderTelemetryGauge(c, makeTelemetry({ model: xss }), 30_000)
const chip = c.querySelector('.tg-model')
expect(chip?.textContent).toBe(xss)
// No actual <script> element should exist inside the container
expect(c.querySelectorAll('script').length).toBe(0)
})
it('skips model chip when model is absent', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry(), 30_000)
expect(c.querySelector('.tg-model')).toBeNull()
})
// ── PR badge ──
it('renders PR badge with link text for a PR', () => {
const c = makeContainer()
renderTelemetryGauge(
c,
makeTelemetry({ pr: { number: 42, url: 'https://github.com/user/repo/pull/42' } }),
30_000,
)
const link = c.querySelector('.tg-pr-link') as HTMLAnchorElement | null
expect(link).not.toBeNull()
expect(link?.textContent).toContain('42')
})
it('sets href for https PR URL (SEC-L5)', () => {
const c = makeContainer()
renderTelemetryGauge(
c,
makeTelemetry({ pr: { number: 42, url: 'https://github.com/user/repo/pull/42' } }),
30_000,
)
const link = c.querySelector('.tg-pr-link') as HTMLAnchorElement | null
expect(link?.href).toBe('https://github.com/user/repo/pull/42')
})
it('does NOT set href for http PR URL (SEC-L5)', () => {
const c = makeContainer()
renderTelemetryGauge(
c,
makeTelemetry({ pr: { number: 99, url: 'http://insecure.example.com/pull/99' } }),
30_000,
)
const link = c.querySelector('.tg-pr-link') as HTMLAnchorElement | null
expect(link?.textContent).toContain('99')
// href attribute should not be set (attribute absent or empty string)
expect(link?.getAttribute('href') ?? '').toBe('')
})
it('does NOT set href for an invalid PR URL (SEC-L5)', () => {
const c = makeContainer()
renderTelemetryGauge(
c,
makeTelemetry({ pr: { number: 1, url: 'not-a-valid-url' } }),
30_000,
)
const link = c.querySelector('.tg-pr-link') as HTMLAnchorElement | null
expect(link?.getAttribute('href') ?? '').toBe('')
})
it('renders PR reviewState as text when present', () => {
const c = makeContainer()
renderTelemetryGauge(
c,
makeTelemetry({ pr: { number: 5, url: 'https://example.com/pull/5', reviewState: 'APPROVED' } }),
30_000,
)
expect(c.querySelector('.tg-pr-state')?.textContent).toBe('APPROVED')
})
it('skips PR reviewState element when reviewState is absent', () => {
const c = makeContainer()
renderTelemetryGauge(
c,
makeTelemetry({ pr: { number: 5, url: 'https://example.com/pull/5' } }),
30_000,
)
expect(c.querySelector('.tg-pr-state')).toBeNull()
})
it('skips PR badge when pr is absent', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry(), 30_000)
expect(c.querySelector('.tg-pr')).toBeNull()
})
// ── stale detection ──
it('adds tg-stale class when telemetry age exceeds staleTtlMs', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry({ at: Date.now() - 60_000 }), 30_000)
expect(c.classList.contains('tg-stale')).toBe(true)
})
it('does NOT add tg-stale class for fresh telemetry', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry({ at: Date.now() }), 30_000)
expect(c.classList.contains('tg-stale')).toBe(false)
})
it('removes tg-stale class when subsequent call has fresh telemetry', () => {
const c = makeContainer()
// First: stale
renderTelemetryGauge(c, makeTelemetry({ at: Date.now() - 60_000 }), 30_000)
expect(c.classList.contains('tg-stale')).toBe(true)
// Second: fresh
renderTelemetryGauge(c, makeTelemetry({ at: Date.now() }), 30_000)
expect(c.classList.contains('tg-stale')).toBe(false)
})
it('removes tg-stale class when called with null telemetry after stale', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry({ at: Date.now() - 60_000 }), 30_000)
expect(c.classList.contains('tg-stale')).toBe(true)
renderTelemetryGauge(c, null, 30_000)
expect(c.classList.contains('tg-stale')).toBe(false)
})
})
// ─────────────────────── renderStatusBadge ───────────────────────────────────
describe('renderStatusBadge', () => {
it('renders a badge for stuck with ⚠ (A5)', () => {
const c = makeContainer()
renderStatusBadge(c, 'stuck')
const badge = c.querySelector('.sb-badge')
expect(badge).not.toBeNull()
expect(badge?.textContent).toContain('⚠')
expect(badge?.textContent).toContain('stuck')
})
it('applies per-status CSS class for styling', () => {
const statuses: ClaudeStatus[] = ['working', 'waiting', 'idle', 'unknown', 'stuck']
for (const status of statuses) {
const c = makeContainer()
renderStatusBadge(c, status)
expect(c.querySelector(`.sb-${status}`)).not.toBeNull()
}
})
it('clears the container before rendering (replaces old badge)', () => {
const c = makeContainer()
renderStatusBadge(c, 'working')
renderStatusBadge(c, 'idle')
expect(c.querySelectorAll('.sb-badge').length).toBe(1)
expect(c.querySelector('.sb-badge')?.textContent).toContain('idle')
})
it('renders each non-stuck status with appropriate text', () => {
const cases: Array<[ClaudeStatus, string]> = [
['working', 'working'],
['waiting', 'waiting'],
['idle', 'idle'],
]
for (const [status, expected] of cases) {
const c = makeContainer()
renderStatusBadge(c, status)
expect(c.querySelector('.sb-badge')?.textContent).toContain(expected)
}
})
})

View File

@@ -456,3 +456,158 @@ describe('show()/refit() re-fit when visible', () => {
expect(ws.sent).toHaveLength(0) expect(ws.sent).toHaveLength(0)
}) })
}) })
// ── B2 telemetry frame ────────────────────────────────────────────────────────
describe('telemetry frame (B2)', () => {
beforeEach(() => setLocation('http:', 'lan:3000'))
function connected(opts: { onTelemetry?: (t: unknown) => void } = {}) {
const s = new TerminalSession({
sessionId: null,
onSessionId: vi.fn(),
onTelemetry: opts.onTelemetry as Parameters<typeof TerminalSession>[0]['onTelemetry'],
})
s.connect()
const ws = MockWebSocket.last()
ws.openIt()
return { s, ws }
}
it('telemetry getter starts as null before any telemetry frame', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
expect(s.telemetry).toBeNull()
})
it('fires onTelemetry callback and updates the telemetry getter', () => {
const onTelemetry = vi.fn()
const { s, ws } = connected({ onTelemetry })
const tel = { at: 1000, contextUsedPct: 42, costUsd: 0.5, model: 'claude-opus-4' }
ws.message(JSON.stringify({ type: 'telemetry', telemetry: tel }))
expect(onTelemetry).toHaveBeenCalledOnce()
expect(onTelemetry).toHaveBeenCalledWith(tel)
expect(s.telemetry).toEqual(tel)
})
it('works without onTelemetry (callback is optional — no throw)', () => {
const { ws } = connected() // no onTelemetry
const tel = { at: 2000, costUsd: 0.1 }
expect(() => ws.message(JSON.stringify({ type: 'telemetry', telemetry: tel }))).not.toThrow()
})
it('updates telemetry on subsequent frames (latest wins)', () => {
const { s, ws } = connected()
ws.message(JSON.stringify({ type: 'telemetry', telemetry: { at: 1000, costUsd: 0.1 } }))
ws.message(JSON.stringify({ type: 'telemetry', telemetry: { at: 2000, costUsd: 0.9 } }))
expect(s.telemetry).toMatchObject({ at: 2000, costUsd: 0.9 })
})
it('onTelemetry is called once per frame (not debounced)', () => {
const onTelemetry = vi.fn()
const { ws } = connected({ onTelemetry })
ws.message(JSON.stringify({ type: 'telemetry', telemetry: { at: 1 } }))
ws.message(JSON.stringify({ type: 'telemetry', telemetry: { at: 2 } }))
ws.message(JSON.stringify({ type: 'telemetry', telemetry: { at: 3 } }))
expect(onTelemetry).toHaveBeenCalledTimes(3)
})
})
// ── B4 pendingGate from status frame ─────────────────────────────────────────
describe('pendingGate from status frame (B4)', () => {
beforeEach(() => setLocation('http:', 'lan:3000'))
function connected() {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
s.connect()
const ws = MockWebSocket.last()
ws.openIt()
return { s, ws }
}
it('pendingGate starts as null', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
expect(s.pendingGate).toBeNull()
})
it('records pendingGate="plan" from a status message with gate:"plan"', () => {
const { s, ws } = connected()
ws.message(JSON.stringify({ type: 'status', status: 'waiting', pending: true, gate: 'plan' }))
expect(s.pendingGate).toBe('plan')
})
it('records pendingGate="tool" from a status message with gate:"tool"', () => {
const { s, ws } = connected()
ws.message(JSON.stringify({ type: 'status', status: 'waiting', pending: true, gate: 'tool' }))
expect(s.pendingGate).toBe('tool')
})
it('sets pendingGate to null when gate is absent from status', () => {
const { s, ws } = connected()
ws.message(JSON.stringify({ type: 'status', status: 'waiting', pending: true }))
expect(s.pendingGate).toBeNull()
})
it('clears pendingGate to null on a subsequent status without gate', () => {
const { s, ws } = connected()
ws.message(JSON.stringify({ type: 'status', status: 'waiting', pending: true, gate: 'plan' }))
expect(s.pendingGate).toBe('plan')
ws.message(JSON.stringify({ type: 'status', status: 'working' }))
expect(s.pendingGate).toBeNull()
})
})
// ── B4 approve(mode?) with PermissionMode ────────────────────────────────────
describe('approve(mode?) — B4 permission-mode relay', () => {
beforeEach(() => setLocation('http:', 'lan:3000'))
function connected() {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
s.connect()
const ws = MockWebSocket.last()
ws.openIt()
ws.message(JSON.stringify({ type: 'status', status: 'waiting', pending: true, gate: 'plan' }))
ws.sent.length = 0
return { s, ws }
}
it('approve() without mode sends {type:"approve"} with NO mode field', () => {
const { s, ws } = connected()
s.approve()
const msg = JSON.parse(ws.sent[0]!)
expect(msg).toEqual({ type: 'approve' })
expect('mode' in msg).toBe(false)
})
it('approve("acceptEdits") sends {type:"approve", mode:"acceptEdits"}', () => {
const { s, ws } = connected()
s.approve('acceptEdits')
expect(JSON.parse(ws.sent[0]!)).toEqual({ type: 'approve', mode: 'acceptEdits' })
})
it('approve("plan") sends {type:"approve", mode:"plan"}', () => {
const { s, ws } = connected()
s.approve('plan')
expect(JSON.parse(ws.sent[0]!)).toEqual({ type: 'approve', mode: 'plan' })
})
it('approve("auto") sends {type:"approve", mode:"auto"}', () => {
const { s, ws } = connected()
s.approve('auto')
expect(JSON.parse(ws.sent[0]!)).toEqual({ type: 'approve', mode: 'auto' })
})
it('approve("default") sends {type:"approve", mode:"default"}', () => {
const { s, ws } = connected()
s.approve('default')
expect(JSON.parse(ws.sent[0]!)).toEqual({ type: 'approve', mode: 'default' })
})
it('approve() clears pendingApproval regardless of mode', () => {
const { s } = connected()
expect(s.pendingApproval).toBe(true)
s.approve('acceptEdits')
expect(s.pendingApproval).toBe(false)
})
})

526
test/timeline.test.ts Normal file
View File

@@ -0,0 +1,526 @@
// @vitest-environment jsdom
/**
* test/timeline.test.ts — N-timeline-ui: activity timeline panel
*
* Covers: normalizeTimelineEvent (defensive), timelineIcon (mapping),
* renderTimelineEvent (textContent/XSS), fetchTimeline (error→[]),
* mountTimeline (polling start/stop, dispose clears interval, empty state,
* newest-first, maxEvents cap).
*
* Security: SEC-H6 (label/toolName rendered via textContent, not innerHTML).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { TimelineEvent, TimelineClass } from '../src/types.js'
// Stub @xterm/xterm (pulled in transitively via preview-grid.ts import).
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 {
normalizeTimelineEvent,
timelineIcon,
renderTimelineEvent,
fetchTimeline,
mountTimeline,
} = await import('../public/timeline.js')
// ─── helpers ────────────────────────────────────────────────────────────────
function makeEvent(over: Partial<TimelineEvent> = {}): TimelineEvent {
return {
at: Date.now(),
class: 'tool',
label: 'ran Bash',
...over,
}
}
function makeRaw(over: Record<string, unknown> = {}): unknown {
return {
at: Date.now(),
class: 'tool',
label: 'ran Bash',
...over,
}
}
// ─── normalizeTimelineEvent ──────────────────────────────────────────────────
describe('normalizeTimelineEvent', () => {
it('returns null for null input', () => {
expect(normalizeTimelineEvent(null)).toBeNull()
})
it('returns null for undefined input', () => {
expect(normalizeTimelineEvent(undefined)).toBeNull()
})
it('returns null for non-object (string)', () => {
expect(normalizeTimelineEvent('hello')).toBeNull()
})
it('returns null for non-object (number)', () => {
expect(normalizeTimelineEvent(42)).toBeNull()
})
it('returns null for array input', () => {
expect(normalizeTimelineEvent([1, 2, 3])).toBeNull()
})
it('returns null when `at` is missing', () => {
expect(normalizeTimelineEvent({ class: 'tool', label: 'hi' })).toBeNull()
})
it('returns null when `at` is a string', () => {
expect(normalizeTimelineEvent(makeRaw({ at: '2024-01-01' }))).toBeNull()
})
it('returns null when `at` is NaN', () => {
expect(normalizeTimelineEvent(makeRaw({ at: NaN }))).toBeNull()
})
it('returns null when `at` is Infinity', () => {
expect(normalizeTimelineEvent(makeRaw({ at: Infinity }))).toBeNull()
})
it('returns null when `label` is missing', () => {
expect(normalizeTimelineEvent({ at: Date.now(), class: 'tool' })).toBeNull()
})
it('returns null when `label` is a number', () => {
expect(normalizeTimelineEvent(makeRaw({ label: 42 }))).toBeNull()
})
it('returns null when `class` is missing', () => {
expect(normalizeTimelineEvent({ at: Date.now(), label: 'hi' })).toBeNull()
})
it('returns null when `class` is an unknown string', () => {
expect(normalizeTimelineEvent(makeRaw({ class: 'unknown-garbage' }))).toBeNull()
})
it('returns null when `class` is not a string', () => {
expect(normalizeTimelineEvent(makeRaw({ class: 99 }))).toBeNull()
})
it('accepts all valid TimelineClass values', () => {
const classes: TimelineClass[] = ['tool', 'waiting', 'done', 'stuck', 'user']
for (const cls of classes) {
const result = normalizeTimelineEvent(makeRaw({ class: cls }))
expect(result).not.toBeNull()
expect(result?.class).toBe(cls)
}
})
it('returns a valid TimelineEvent for a well-formed input', () => {
const now = Date.now()
const result = normalizeTimelineEvent({ at: now, class: 'done', label: 'session ended' })
expect(result).toEqual({ at: now, class: 'done', label: 'session ended' })
})
it('includes toolName when it is a string', () => {
const result = normalizeTimelineEvent(makeRaw({ toolName: 'Bash' }))
expect(result?.toolName).toBe('Bash')
})
it('omits toolName when it is not a string', () => {
const result = normalizeTimelineEvent(makeRaw({ toolName: 123 }))
expect(result?.toolName).toBeUndefined()
})
it('truncates label at 500 characters', () => {
const longLabel = 'x'.repeat(600)
const result = normalizeTimelineEvent(makeRaw({ label: longLabel }))
expect(result?.label.length).toBeLessThanOrEqual(500)
})
it('truncates toolName at 200 characters', () => {
const longName = 'y'.repeat(300)
const result = normalizeTimelineEvent(makeRaw({ toolName: longName }))
expect(result?.toolName?.length).toBeLessThanOrEqual(200)
})
it('does not throw on arbitrary garbage inputs', () => {
const garbage = [
{},
[],
null,
undefined,
false,
0,
'',
{ at: null, class: {}, label: [] },
{ at: 1, class: 'tool', label: 'ok', toolName: {} },
]
for (const g of garbage) {
expect(() => normalizeTimelineEvent(g)).not.toThrow()
}
})
})
// ─── timelineIcon ────────────────────────────────────────────────────────────
describe('timelineIcon', () => {
it('returns a non-empty string for every TimelineClass', () => {
const classes: TimelineClass[] = ['tool', 'waiting', 'done', 'stuck', 'user']
for (const cls of classes) {
const icon = timelineIcon(cls)
expect(typeof icon).toBe('string')
expect(icon.length).toBeGreaterThan(0)
}
})
it('maps tool to a tool-related icon', () => {
expect(timelineIcon('tool')).toBeTruthy()
})
it('maps waiting to a waiting icon', () => {
expect(timelineIcon('waiting')).toBeTruthy()
})
it('maps done to a done/check icon', () => {
const icon = timelineIcon('done')
expect(typeof icon).toBe('string')
})
it('maps stuck to a warning icon containing ⚠', () => {
const icon = timelineIcon('stuck')
expect(icon).toContain('⚠')
})
it('maps user to a user icon', () => {
expect(timelineIcon('user')).toBeTruthy()
})
it('returns distinct icons for each class', () => {
const classes: TimelineClass[] = ['tool', 'waiting', 'done', 'stuck', 'user']
const icons = classes.map(timelineIcon)
const unique = new Set(icons)
expect(unique.size).toBe(classes.length)
})
})
// ─── renderTimelineEvent ─────────────────────────────────────────────────────
describe('renderTimelineEvent', () => {
it('returns an HTMLElement', () => {
const el = renderTimelineEvent(makeEvent())
expect(el).toBeInstanceOf(HTMLElement)
})
it('does NOT use innerHTML (textContent only — SEC-H6)', () => {
const ev = makeEvent({ label: '<script>alert(1)</script>' })
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('<script>alert(1)</script>')
})
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: '<b>bold</b> & "quotes"' })
const row = renderTimelineEvent(ev)
expect(row.querySelectorAll('b').length).toBe(0)
expect(row.textContent).toContain('<b>bold</b>')
})
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<void> {
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: '<script>xss</script>' }],
}),
)
const container = makeContainer()
const handle = mountTimeline(container, 'sess-xss')
await flushPromises()
expect(container.querySelectorAll('script').length).toBe(0)
expect(container.textContent).toContain('<script>xss</script>')
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()
})
})

447
test/voice.test.ts Normal file
View File

@@ -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 = '<div id="keybar"></div>'
})
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<HTMLElement>('[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()
})
})

551
test/worktree-form.test.ts Normal file
View File

@@ -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> = {}): 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 — <script> appears as literal text', async () => {
const xss = '<script>alert(1)</script>'
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 <script> element in DOM — textContent was used
expect(errorEl.querySelector('script')).toBeNull()
expect(errorEl.textContent).toBe(xss)
})
it('hides the error element when the user edits the input field', () => {
const form = renderNewWorktreeForm(makeDetail(), makeHooks())
// Trigger a validation error first
;(form.querySelector('button') as HTMLButtonElement).click()
const errorEl = form.querySelector('.proj-wt-error') as HTMLElement
expect(errorEl.style.display).not.toBe('none')
// Simulate typing in the input
const input = form.querySelector('input') as HTMLInputElement
input.value = 'f'
input.dispatchEvent(new Event('input'))
expect(errorEl.style.display).toBe('none')
})
})
/* ── renderProjectDetail — B1: View Diff ──────────────────────────────────── */
describe('renderProjectDetail — B1 View Diff', () => {
it('shows a "View Diff" toggle button for git repos', () => {
const root = renderProjectDetail(makeDetail({ isGit: true }), makeHooks(), makeCbs())
expect(root.querySelector('.proj-diff-toggle')).not.toBeNull()
})
it('does NOT show "View Diff" button for non-git directories', () => {
const root = renderProjectDetail(
makeDetail({ isGit: false, branch: undefined }),
makeHooks(),
makeCbs(),
)
expect(root.querySelector('.proj-diff-toggle')).toBeNull()
})
it('diff panel is hidden initially', () => {
const root = renderProjectDetail(makeDetail({ isGit: true }), makeHooks(), makeCbs())
const panel = root.querySelector('.proj-diff-panel') as HTMLElement
expect(panel.style.display).toBe('none')
})
it('clicking "View Diff" calls mountDiffViewer with the project path', () => {
const detail = makeDetail({ path: '/home/user/proj', isGit: true })
const root = renderProjectDetail(detail, makeHooks(), makeCbs())
;(root.querySelector('.proj-diff-toggle') as HTMLButtonElement).click()
expect(mockMountDiffViewer).toHaveBeenCalledWith(
expect.any(HTMLElement),
'/home/user/proj',
expect.objectContaining({ onClose: expect.any(Function) }),
)
})
it('clicking "View Diff" shows the diff panel', () => {
const root = renderProjectDetail(makeDetail({ isGit: true }), makeHooks(), makeCbs())
const panel = root.querySelector('.proj-diff-panel') as HTMLElement
;(root.querySelector('.proj-diff-toggle') as HTMLButtonElement).click()
expect(panel.style.display).not.toBe('none')
})
it('the onClose callback passed to mountDiffViewer hides the panel', () => {
const root = renderProjectDetail(makeDetail({ isGit: true }), makeHooks(), makeCbs())
;(root.querySelector('.proj-diff-toggle') as HTMLButtonElement).click()
const opts = mockMountDiffViewer.mock.calls[0]?.[2] as { onClose?: () => void }
opts?.onClose?.()
const panel = root.querySelector('.proj-diff-panel') as HTMLElement
expect(panel.style.display).toBe('none')
})
it('clicking the toggle again closes the diff panel and calls destroy()', () => {
const root = renderProjectDetail(makeDetail({ isGit: true }), makeHooks(), makeCbs())
const toggle = root.querySelector('.proj-diff-toggle') as HTMLButtonElement
toggle.click() // open
toggle.click() // close
expect(mockDiffHandle.destroy).toHaveBeenCalled()
const panel = root.querySelector('.proj-diff-panel') as HTMLElement
expect(panel.style.display).toBe('none')
})
})
/* ── renderProjectDetail — B3: New Worktree Form ──────────────────────────── */
describe('renderProjectDetail — B3 New Worktree Form', () => {
it('includes a worktree form for git repos', () => {
const root = renderProjectDetail(makeDetail({ isGit: true }), makeHooks(), makeCbs())
expect(root.querySelector('.proj-wt-form')).not.toBeNull()
})
it('does NOT include a worktree form for non-git directories', () => {
const root = renderProjectDetail(
makeDetail({ isGit: false, branch: undefined }),
makeHooks(),
makeCbs(),
)
expect(root.querySelector('.proj-wt-form')).toBeNull()
})
})
/* ── renderProjectDetail — A4: Activity section ───────────────────────────── */
describe('renderProjectDetail — A4 Activity section', () => {
const runningSess = {
id: 'sess-1',
title: 'claude',
status: 'working' as const,
clientCount: 1,
createdAt: 1,
exited: false,
}
const exitedSess = {
id: 'sess-2',
title: 'shell',
status: 'idle' as const,
clientCount: 0,
createdAt: 2,
exited: true,
}
it('shows the activity section when at least one session is running', () => {
const root = renderProjectDetail(
makeDetail({ sessions: [runningSess] }),
makeHooks(),
makeCbs(),
)
expect(root.querySelector('.proj-activity-section')).not.toBeNull()
})
it('mounts a timeline for each running session', () => {
renderProjectDetail(makeDetail({ sessions: [runningSess] }), makeHooks(), makeCbs())
expect(mockMountTimeline).toHaveBeenCalledWith(expect.any(HTMLElement), 'sess-1')
})
it('does NOT mount timelines for exited sessions', () => {
renderProjectDetail(makeDetail({ sessions: [exitedSess] }), makeHooks(), makeCbs())
expect(mockMountTimeline).not.toHaveBeenCalled()
})
it('mounts one timeline per running session', () => {
const sess2 = { id: 'sess-3', title: 'codex', status: 'working' as const, clientCount: 1, createdAt: 3, exited: false }
renderProjectDetail(
makeDetail({ sessions: [runningSess, sess2] }),
makeHooks(),
makeCbs(),
)
expect(mockMountTimeline).toHaveBeenCalledTimes(2)
expect(mockMountTimeline).toHaveBeenCalledWith(expect.any(HTMLElement), 'sess-1')
expect(mockMountTimeline).toHaveBeenCalledWith(expect.any(HTMLElement), 'sess-3')
})
it('disposes timeline handles when onBack is clicked', () => {
const cb = makeCbs()
const root = renderProjectDetail(makeDetail({ sessions: [runningSess] }), makeHooks(), cb)
expect(mockTimelineHandle.dispose).not.toHaveBeenCalled()
;(root.querySelector('.proj-back') as HTMLButtonElement).click()
expect(mockTimelineHandle.dispose).toHaveBeenCalled()
expect(cb.onBack).toHaveBeenCalled()
})
it('hides the activity section when no sessions are running', () => {
const root = renderProjectDetail(makeDetail({ sessions: [] }), makeHooks(), makeCbs())
expect(root.querySelector('.proj-activity-section')).toBeNull()
expect(mockMountTimeline).not.toHaveBeenCalled()
})
it('hides the activity section when all sessions are exited', () => {
const root = renderProjectDetail(makeDetail({ sessions: [exitedSess] }), makeHooks(), makeCbs())
expect(root.querySelector('.proj-activity-section')).toBeNull()
})
it('timeline collector receives handles when provided (re-render disposal)', () => {
const collector: Array<{ dispose(): void }> = []
renderProjectDetail(
makeDetail({ sessions: [runningSess] }),
makeHooks(),
makeCbs(),
collector,
)
expect(collector).toHaveLength(1)
expect(collector[0]).toBe(mockTimelineHandle)
})
it('disposes the diff handle on back when the diff panel was open', () => {
const root = renderProjectDetail(makeDetail({ isGit: true }), makeHooks(), makeCbs())
// Open diff
;(root.querySelector('.proj-diff-toggle') as HTMLButtonElement).click()
// Press back
;(root.querySelector('.proj-back') as HTMLButtonElement).click()
expect(mockDiffHandle.destroy).toHaveBeenCalled()
})
})