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:
@@ -146,6 +146,15 @@
|
||||
- **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` 续跑 W1–W4。
|
||||
- **W4 评审**: 安全 6 项全过(每个状态变更路由带 Origin/CSRF;VAPID 私钥不外泄/不记日志;git `execFile` 无注入;WS Origin 防御无回归)。**遗留(非阻塞, 无 CRITICAL/HIGH)**: ① protocol.ts `approve.mode` 校验应入 `parseClientMessage`(现 server.ts `parseApproveMode` 兜底, 功能与安全正确)② CSP `connect-src` 去掉 `ws:/wss:` 通配(既有, 低危)③ worktree `base` ref 加格式校验 ④ protocol/preview-grid 补 2 个针对性测试。完整 27 条任务日志见 workflow result 文件。
|
||||
- **验证**: `npx tsc -p tsconfig.json/.web.json --noEmit` 干净;`npx vitest run --coverage` 全绿 ≥80×4;`npm run build:web` OK;`web-push` 已 npm install。
|
||||
- **commit**: (本次提交)
|
||||
|
||||
### 2026-06-30 · v0.6 工具栏图标换成线性图标 + hover tooltip
|
||||
|
||||
- **状态**: `[x]` DONE。web typecheck 干净 · `build:web` OK · 真浏览器验证通过。
|
||||
|
||||
151
package-lock.json
generated
151
package-lock.json
generated
@@ -16,12 +16,14 @@
|
||||
"express": "^5.2.1",
|
||||
"node-pty": "^1.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"web-push": "3.6.7",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/web-push": "3.6.4",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"esbuild": "^0.28.1",
|
||||
@@ -1264,6 +1266,16 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/web-push": {
|
||||
"version": "3.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
||||
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
@@ -1458,6 +1470,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
@@ -1482,6 +1503,18 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1.js": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bn.js": "^4.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
"minimalistic-assert": "^1.0.0",
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
@@ -1514,6 +1547,12 @@
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "4.12.4",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.4.tgz",
|
||||
"integrity": "sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||
@@ -1551,6 +1590,12 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -1784,6 +1829,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -2181,6 +2235,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/http_ece": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@@ -2201,6 +2264,19 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
@@ -2341,6 +2417,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
@@ -2724,6 +2821,21 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -3117,6 +3229,26 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
@@ -3751,6 +3883,25 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/web-push": {
|
||||
"version": "3.6.7",
|
||||
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"asn1.js": "^5.3.0",
|
||||
"http_ece": "1.2.0",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"jws": "^4.0.0",
|
||||
"minimist": "^1.2.5"
|
||||
},
|
||||
"bin": {
|
||||
"web-push": "src/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||
|
||||
@@ -27,12 +27,14 @@
|
||||
"express": "^5.2.1",
|
||||
"node-pty": "^1.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"web-push": "3.6.7",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/web-push": "3.6.4",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"esbuild": "^0.28.1",
|
||||
|
||||
347
public/diff.ts
Normal file
347
public/diff.ts
Normal 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 = ''
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,10 @@
|
||||
* Tab \t complete / toggle
|
||||
* ← → \x1b[D/C cursor
|
||||
* / / 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). */
|
||||
export const KEY_MAP = {
|
||||
@@ -78,8 +79,32 @@ export const KEYBAR_BUTTONS: KeybarButton[] = [
|
||||
{ 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')
|
||||
if (!keybarEl) return
|
||||
|
||||
@@ -115,4 +140,50 @@ export const mountKeybar: MountKeybar = (onSend) => {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
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. */
|
||||
export interface SessionPreview {
|
||||
@@ -44,9 +44,13 @@ export function relTime(ms: number): string {
|
||||
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 {
|
||||
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. */
|
||||
@@ -178,3 +182,86 @@ export async function fetchLiveSessions(): Promise<LiveSessionInfo[]> {
|
||||
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)))
|
||||
}
|
||||
|
||||
@@ -19,9 +19,12 @@ import type {
|
||||
ClaudeStatus,
|
||||
ProjectDetail,
|
||||
WorktreeInfo,
|
||||
CreateWorktreeResult,
|
||||
} from '../src/types.js'
|
||||
import { el, relTime, statusText } from './preview-grid.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 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -405,21 +408,166 @@ function makeDetailSessionRow(
|
||||
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 {
|
||||
onBack: () => 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(
|
||||
detail: ProjectDetail | null,
|
||||
hooks: ProjectsHooks,
|
||||
cb: DetailCallbacks,
|
||||
timelineCollector?: TimelineHandle[],
|
||||
): HTMLElement {
|
||||
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')
|
||||
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)
|
||||
|
||||
if (detail === null) {
|
||||
@@ -439,6 +587,9 @@ export function renderProjectDetail(
|
||||
root.append(head)
|
||||
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
|
||||
root.append(el('div', 'proj-section-title', detail.worktrees.length > 1 ? 'Worktrees' : 'Branch'))
|
||||
if (!detail.isGit) {
|
||||
@@ -453,6 +604,12 @@ export function renderProjectDetail(
|
||||
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
|
||||
const running = detail.sessions.filter((s) => !s.exited)
|
||||
root.append(el('div', 'proj-section-title', `Active sessions (${running.length})`))
|
||||
@@ -464,15 +621,20 @@ export function renderProjectDetail(
|
||||
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')
|
||||
cmHead.append(el('div', 'proj-section-title', 'CLAUDE.md'))
|
||||
const cmBtn = el('button', 'proj-claudemd-btn', detail.hasClaudeMd ? '↻ Update' : '✨ Generate')
|
||||
cmBtn.title = detail.hasClaudeMd
|
||||
? 'Open a Claude session running /init to refresh 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'))
|
||||
cmHead.append(cmBtn)
|
||||
root.append(cmHead)
|
||||
@@ -483,11 +645,7 @@ export function renderProjectDetail(
|
||||
root.append(pre)
|
||||
} else {
|
||||
root.append(
|
||||
el(
|
||||
'div',
|
||||
'proj-empty',
|
||||
'No CLAUDE.md yet — generate one to give Claude project-specific instructions.',
|
||||
),
|
||||
el('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 view: 'grid' | 'detail' = 'grid'
|
||||
let detailPath: string | null = null
|
||||
// A4: tracks timeline handles from the current detail render for re-render disposal
|
||||
let detailTimelineHandles: TimelineHandle[] = []
|
||||
|
||||
function applyView(): void {
|
||||
const isGrid = view === 'grid'
|
||||
@@ -587,15 +747,23 @@ export function mountProjects(host: HTMLElement, hooks: ProjectsHooks): Projects
|
||||
}
|
||||
|
||||
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(
|
||||
renderProjectDetail(detail, hooks, {
|
||||
renderProjectDetail(
|
||||
detail,
|
||||
hooks,
|
||||
{
|
||||
onBack: closeDetail,
|
||||
onKill: (id) => {
|
||||
void killSession(id).then(() => {
|
||||
if (view === 'detail') void refresh()
|
||||
})
|
||||
},
|
||||
}),
|
||||
},
|
||||
detailTimelineHandles,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
354
public/push.ts
Normal file
354
public/push.ts
Normal 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
279
public/quick-reply.ts
Normal 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
82
public/sw-push.js
Normal 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)}`,
|
||||
}
|
||||
}
|
||||
71
public/sw.js
71
public/sw.js
@@ -1,10 +1,16 @@
|
||||
/**
|
||||
* public/sw.js — minimal service worker (M4: PWA installability + offline shell).
|
||||
* public/sw.js — service worker (ES module; must be registered with {type:'module'}).
|
||||
*
|
||||
* Network-first so we never serve a stale build while online; falls back to the
|
||||
* cache when offline. Never intercepts the WebSocket (/term) or hook endpoints.
|
||||
* Network-first caching + A1 push notifications + notificationclick routing.
|
||||
* Pure push/notification helpers live in sw-push.js (no SW globals → testable).
|
||||
*
|
||||
* IMPORTANT: This file uses ES module `import` syntax. The SW registration in
|
||||
* public/main.ts must pass { type: 'module' }:
|
||||
* navigator.serviceWorker.register('./sw.js', { type: 'module' })
|
||||
*/
|
||||
|
||||
import { buildPushNotification, resolveNotificationClick } from './sw-push.js'
|
||||
|
||||
const CACHE = 'webterm-v1'
|
||||
|
||||
self.addEventListener('install', () => self.skipWaiting())
|
||||
@@ -14,7 +20,13 @@ self.addEventListener('fetch', (e) => {
|
||||
const req = e.request
|
||||
if (req.method !== 'GET') return
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/term' || url.pathname.startsWith('/hook')) return // WS / hooks
|
||||
// Bypass: WS upgrade, hook endpoints, and push subscription routes (A1)
|
||||
if (
|
||||
url.pathname === '/term' ||
|
||||
url.pathname.startsWith('/hook') ||
|
||||
url.pathname.startsWith('/push')
|
||||
)
|
||||
return
|
||||
|
||||
e.respondWith(
|
||||
fetch(req)
|
||||
@@ -26,3 +38,54 @@ self.addEventListener('fetch', (e) => {
|
||||
.catch(() => caches.match(req)),
|
||||
)
|
||||
})
|
||||
|
||||
/** A1: show a notification when a push arrives. */
|
||||
self.addEventListener('push', (e) => {
|
||||
let payload
|
||||
try {
|
||||
payload = e.data?.json()
|
||||
} catch {
|
||||
return // malformed push — ignore
|
||||
}
|
||||
if (!payload || typeof payload !== 'object') return
|
||||
|
||||
const { title, options } = buildPushNotification(payload)
|
||||
e.waitUntil(self.registration.showNotification(title, options))
|
||||
})
|
||||
|
||||
/**
|
||||
* A1: handle notification action clicks.
|
||||
* Allow/Deny → POST /hook/decision with capability token (SEC-C1).
|
||||
* Body click → focus / open the terminal session tab.
|
||||
*/
|
||||
self.addEventListener('notificationclick', (e) => {
|
||||
e.notification.close()
|
||||
const result = resolveNotificationClick(e.notification, e.action)
|
||||
|
||||
if (result.kind === 'decision') {
|
||||
// credentials:'same-origin' ensures Origin header is sent so the server
|
||||
// can validate it alongside the capability token (SEC-C1).
|
||||
e.waitUntil(
|
||||
fetch('/hook/decision', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: result.body,
|
||||
}).catch((err) => {
|
||||
console.error('[sw] /hook/decision fetch failed', err)
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
// Focus an existing window if available, otherwise open a new one.
|
||||
e.waitUntil(
|
||||
self.clients
|
||||
.matchAll({ type: 'window', includeUncontrolled: true })
|
||||
.then((clientList) => {
|
||||
for (const client of clientList) {
|
||||
if ('focus' in client) return client.focus()
|
||||
}
|
||||
return self.clients.openWindow(result.url)
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
307
public/tabs.ts
307
public/tabs.ts
@@ -25,10 +25,25 @@ import { TerminalSession } from './terminal-session.js'
|
||||
import { THEMES, DEFAULT_SETTINGS, type Settings } from './settings.js'
|
||||
import { mountLauncher, type Launcher } from './launcher.js'
|
||||
import { mountProjects, type ProjectsPanel } from './projects.js'
|
||||
import type { ClaudeStatus, PermissionMode, UiConfig } from '../src/types.js'
|
||||
import { renderTelemetryGauge } from './preview-grid.js'
|
||||
import { mountPushToggle } from './push.js'
|
||||
import { mountQuickReply } from './quick-reply.js'
|
||||
import { mountTimeline, type TimelineHandle } from './timeline.js'
|
||||
import { createVoiceInput, type VoiceInput } from './voice.js'
|
||||
|
||||
const TABS_KEY = 'web-terminal:tabs'
|
||||
const ACTIVE_KEY = 'web-terminal:active'
|
||||
const HOME_VIEW_KEY = 'web-terminal:home-view'
|
||||
const PERMISSION_MODE_KEY = 'web-terminal:permission-mode'
|
||||
|
||||
/** Mirrors the server STATUSLINE_TTL_MS default — the per-tab gauge greys out
|
||||
* (class `tg-stale`) once telemetry is older than this (B2). */
|
||||
const STATUSLINE_TTL_MS = 30_000
|
||||
|
||||
/** All `--permission-mode` values (B4). 'auto' is high-risk and only offered
|
||||
* when the server's ALLOW_AUTO_MODE allows it (SEC-M5, gated via /config/ui). */
|
||||
const ALL_PERMISSION_MODES: readonly PermissionMode[] = ['default', 'acceptEdits', 'plan', 'auto']
|
||||
|
||||
type HomeView = 'sessions' | 'projects'
|
||||
|
||||
@@ -38,6 +53,10 @@ interface TabEntry {
|
||||
autoTitle: string | null // current folder from the terminal title
|
||||
hasActivity: boolean // inactive tab got output since last viewed
|
||||
el: HTMLDivElement | null // the .tab element (updated in place)
|
||||
// A4: live timeline handle while this tab's timeline is open (disposed on
|
||||
// switch/close). Telemetry is NOT cached here — refreshTab reads the single
|
||||
// source of truth session.telemetry (review #15).
|
||||
timelineHandle: TimelineHandle | null
|
||||
}
|
||||
|
||||
interface StoredTab {
|
||||
@@ -45,6 +64,15 @@ interface StoredTab {
|
||||
title: string | null
|
||||
}
|
||||
|
||||
/** Compact glyph for a tab's Claude activity, incl. 'stuck' ⚠ (A5). */
|
||||
function claudeIcon(cs: ClaudeStatus): string {
|
||||
if (cs === 'working') return '⚙'
|
||||
if (cs === 'waiting') return '⏳'
|
||||
if (cs === 'idle') return '✓'
|
||||
if (cs === 'stuck') return '⚠'
|
||||
return ''
|
||||
}
|
||||
|
||||
export class TabApp {
|
||||
private readonly tabs: TabEntry[] = []
|
||||
private activeIndex = -1
|
||||
@@ -64,6 +92,15 @@ export class TabApp {
|
||||
// tab is activated. Irrelevant while no tab is open (home shows regardless).
|
||||
private homeForced = false
|
||||
private homeBtn: HTMLButtonElement | null = null
|
||||
// B4: mirrors the server ALLOW_AUTO_MODE gate (from /config/ui); when false the
|
||||
// high-risk 'auto' permission mode is hidden/refused (SEC-M5).
|
||||
private allowAutoMode = false
|
||||
private pushHost!: HTMLElement // A1: 🔔 host, mounted once, re-parented per rebuild
|
||||
private timelinePanel!: HTMLElement // A4: shared timeline panel (one mounted at a time)
|
||||
private timelineOpen = false
|
||||
private timelineBtn: HTMLButtonElement | null = null
|
||||
private voice: VoiceInput | null = null // A2: push-to-talk recognizer
|
||||
private voiceOverlay: HTMLElement | null = null // A2: interim-transcript overlay
|
||||
|
||||
constructor(paneHost: HTMLElement, tabBar: HTMLElement) {
|
||||
this.paneHost = paneHost
|
||||
@@ -94,6 +131,13 @@ export class TabApp {
|
||||
this.segControl = this.buildSegControl()
|
||||
this.paneHost.appendChild(this.segControl)
|
||||
|
||||
// v0.7 Walk-away Workbench panels (mounted once; survive tab rebuilds):
|
||||
this.setupPushToggle() // A1 🔔
|
||||
this.setupQuickReply() // A3 chips above the key bar
|
||||
this.setupTimelinePanel() // A4 activity timeline
|
||||
this.setupVoiceOverlay() // A2 interim-transcript overlay
|
||||
void this.loadUiConfig() // B4 allowAutoMode gate (best-effort)
|
||||
|
||||
// v0.5: do NOT auto-create or auto-restore tabs. Land on the home screen;
|
||||
// the user picks which session to open. Sessions persist server-side, so
|
||||
// opening one replays its full scrollback.
|
||||
@@ -101,6 +145,61 @@ export class TabApp {
|
||||
this.updateHomeView()
|
||||
}
|
||||
|
||||
/* ── v0.7 panel setup (A1/A2/A3/A4/B4) ──────────────────────────── */
|
||||
|
||||
/** A1: mount the 🔔 push toggle once into a host re-parented on every rebuild. */
|
||||
private setupPushToggle(): void {
|
||||
this.pushHost = document.createElement('div')
|
||||
this.pushHost.className = 'push-host'
|
||||
mountPushToggle(this.pushHost)
|
||||
}
|
||||
|
||||
/** A3: quick-reply chips row, inserted directly above the #keybar. */
|
||||
private setupQuickReply(): void {
|
||||
const host = document.createElement('div')
|
||||
host.id = 'quickreply'
|
||||
const keybar = document.getElementById('keybar')
|
||||
if (keybar?.parentElement) keybar.parentElement.insertBefore(host, keybar)
|
||||
else document.body.appendChild(host)
|
||||
mountQuickReply(host, { onSend: (data) => this.sendToActive(data) })
|
||||
}
|
||||
|
||||
/** A4: hidden activity-timeline panel; toggled per active session. */
|
||||
private setupTimelinePanel(): void {
|
||||
this.timelinePanel = document.createElement('div')
|
||||
this.timelinePanel.id = 'timeline-panel'
|
||||
this.timelinePanel.style.display = 'none'
|
||||
document.body.appendChild(this.timelinePanel)
|
||||
}
|
||||
|
||||
/** A2: overlay that shows the live interim transcript while dictating. */
|
||||
private setupVoiceOverlay(): void {
|
||||
const overlay = document.createElement('div')
|
||||
overlay.id = 'voice-interim'
|
||||
overlay.style.display = 'none'
|
||||
document.body.appendChild(overlay)
|
||||
this.voiceOverlay = overlay
|
||||
}
|
||||
|
||||
/** B4: fetch the server UI config (allowAutoMode). Best-effort, never throws. */
|
||||
private async loadUiConfig(): Promise<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 ────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
@@ -189,7 +288,8 @@ export class TabApp {
|
||||
|
||||
/* ── Approval bar ───────────────────────────────────────────────── */
|
||||
|
||||
/** Show/hide the approve/reject banner for the active tab's held request (H3). */
|
||||
/** Show/hide the approve/reject banner (H3). B4: a 'plan' gate renders THREE
|
||||
* choices; an ordinary 'tool' gate keeps the two buttons (no regression). */
|
||||
private updateApprovalBar(): void {
|
||||
const session = this.tabs[this.activeIndex]?.session
|
||||
if (!session || !session.pendingApproval) {
|
||||
@@ -199,25 +299,150 @@ export class TabApp {
|
||||
this.approvalBar.replaceChildren()
|
||||
const label = document.createElement('span')
|
||||
label.className = 'approval-label'
|
||||
if (session.pendingGate === 'plan') {
|
||||
label.textContent = 'Claude finished planning — how should it proceed?'
|
||||
this.approvalBar.append(label, ...this.planGateButtons(session))
|
||||
} else {
|
||||
label.textContent = `Claude wants to use ${session.pendingTool ?? 'a tool'}`
|
||||
const approve = document.createElement('button')
|
||||
approve.className = 'approval-yes'
|
||||
approve.textContent = '✓ Approve'
|
||||
approve.addEventListener('click', () => {
|
||||
session.approve()
|
||||
this.updateApprovalBar()
|
||||
})
|
||||
const reject = document.createElement('button')
|
||||
reject.className = 'approval-no'
|
||||
reject.textContent = '✗ Reject'
|
||||
reject.addEventListener('click', () => {
|
||||
session.reject()
|
||||
this.updateApprovalBar()
|
||||
})
|
||||
this.approvalBar.append(label, approve, reject)
|
||||
this.approvalBar.append(label, ...this.toolGateButtons(session))
|
||||
}
|
||||
this.approvalBar.style.display = 'flex'
|
||||
}
|
||||
|
||||
/** Ordinary tool gate: Approve / Reject (two buttons, unchanged). */
|
||||
private toolGateButtons(session: TerminalSession): HTMLButtonElement[] {
|
||||
return [
|
||||
this.approvalButton('approval-yes', '✓ Approve', () => session.approve()),
|
||||
this.approvalButton('approval-no', '✗ Reject', () => session.reject()),
|
||||
]
|
||||
}
|
||||
|
||||
/** B4 plan gate: Approve+Auto (acceptEdits) / Approve+Review (default) / Keep
|
||||
* Planning (reject — stays in plan mode). Three buttons. */
|
||||
private planGateButtons(session: TerminalSession): HTMLButtonElement[] {
|
||||
return [
|
||||
this.approvalButton('approval-yes', '✓ Approve + Auto', () => session.approve('acceptEdits')),
|
||||
this.approvalButton('approval-review', '✓ Approve + Review', () => session.approve('default')),
|
||||
this.approvalButton('approval-no', '✎ Keep Planning', () => session.reject()),
|
||||
]
|
||||
}
|
||||
|
||||
/** Build one approval-bar button that runs `onClick` then re-renders the bar. */
|
||||
private approvalButton(cls: string, text: string, onClick: () => void): HTMLButtonElement {
|
||||
const btn = document.createElement('button')
|
||||
btn.className = cls
|
||||
btn.textContent = text
|
||||
btn.addEventListener('click', () => {
|
||||
onClick()
|
||||
this.updateApprovalBar()
|
||||
})
|
||||
return btn
|
||||
}
|
||||
|
||||
/* ── A2 voice · A4 timeline · B4 permission mode ─────────────────── */
|
||||
|
||||
/** Push-to-talk handler for the key-bar 🎤 (A2). Wire into mountKeybar via
|
||||
* `{ onVoiceTrigger: (a) => app.handleVoiceTrigger(a) }`. */
|
||||
handleVoiceTrigger(action: 'start' | 'stop'): void {
|
||||
if (action === 'start') this.startVoice()
|
||||
else this.stopVoice()
|
||||
}
|
||||
|
||||
private startVoice(): void {
|
||||
if (!this.voice) {
|
||||
this.voice = createVoiceInput((text) => this.sendToActive(text), {
|
||||
onInterim: (t) => this.showInterim(t),
|
||||
})
|
||||
}
|
||||
if (!this.voice) return // SpeechRecognition unsupported — no-op (AC-A2.3)
|
||||
this.showInterim('')
|
||||
this.voice.start()
|
||||
}
|
||||
|
||||
private stopVoice(): void {
|
||||
this.voice?.stop()
|
||||
this.hideInterim()
|
||||
}
|
||||
|
||||
private showInterim(text: string): void {
|
||||
if (!this.voiceOverlay) return
|
||||
this.voiceOverlay.textContent = text // textContent — transcript is untrusted
|
||||
this.voiceOverlay.style.display = 'block'
|
||||
}
|
||||
|
||||
private hideInterim(): void {
|
||||
if (this.voiceOverlay) this.voiceOverlay.style.display = 'none'
|
||||
}
|
||||
|
||||
/** Toggle the activity-timeline panel for the active session (A4). */
|
||||
private toggleTimeline(): void {
|
||||
this.timelineOpen = !this.timelineOpen
|
||||
if (this.timelineOpen) this.openTimelineForActive()
|
||||
else this.closeTimeline()
|
||||
this.timelineBtn?.classList.toggle('active', this.timelineOpen)
|
||||
}
|
||||
|
||||
/** Mount the timeline for the active session (one panel at a time, A4). */
|
||||
private openTimelineForActive(): void {
|
||||
for (const t of this.tabs) {
|
||||
t.timelineHandle?.dispose()
|
||||
t.timelineHandle = null
|
||||
}
|
||||
this.timelinePanel.style.display = 'block'
|
||||
this.timelinePanel.replaceChildren()
|
||||
const entry = this.tabs[this.activeIndex]
|
||||
const id = entry?.session.id
|
||||
if (entry && id) entry.timelineHandle = mountTimeline(this.timelinePanel, id)
|
||||
}
|
||||
|
||||
private closeTimeline(): void {
|
||||
this.timelinePanel.style.display = 'none'
|
||||
for (const t of this.tabs) {
|
||||
t.timelineHandle?.dispose()
|
||||
t.timelineHandle = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Permission modes the user may pick (B4). 'auto' is filtered out unless the
|
||||
* server allows it (SEC-M5). */
|
||||
availablePermissionModes(): PermissionMode[] {
|
||||
return ALL_PERMISSION_MODES.filter((m) => m !== 'auto' || this.allowAutoMode)
|
||||
}
|
||||
|
||||
/** B4: persisted default permission mode; 'auto' downgrades to 'default' when
|
||||
* the server forbids it (SEC-M5). Invalid stored values fall back to 'default'. */
|
||||
loadDefaultMode(): PermissionMode {
|
||||
let mode: PermissionMode = 'default'
|
||||
try {
|
||||
const stored = localStorage.getItem(PERMISSION_MODE_KEY)
|
||||
if (stored !== null && (ALL_PERMISSION_MODES as readonly string[]).includes(stored)) {
|
||||
mode = stored as PermissionMode
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable — use 'default'
|
||||
}
|
||||
return this.resolveMode(mode)
|
||||
}
|
||||
|
||||
/** Persist the chosen default permission mode (B4-FR7). */
|
||||
saveDefaultMode(mode: PermissionMode): void {
|
||||
try {
|
||||
localStorage.setItem(PERMISSION_MODE_KEY, mode)
|
||||
} catch {
|
||||
// localStorage unavailable — run without persistence
|
||||
}
|
||||
}
|
||||
|
||||
/** SEC-M5: never honor 'auto' when the server forbids it. */
|
||||
private resolveMode(mode: PermissionMode): PermissionMode {
|
||||
return mode === 'auto' && !this.allowAutoMode ? 'default' : mode
|
||||
}
|
||||
|
||||
/** B4: `claude` launch command for a permission mode (default → plain claude). */
|
||||
private buildClaudeCmd(mode: PermissionMode): string {
|
||||
return mode === 'default' ? 'claude\r' : `claude --permission-mode ${mode}\r`
|
||||
}
|
||||
|
||||
private displayTitle(entry: TabEntry, idx: number): string {
|
||||
return entry.customTitle ?? entry.autoTitle ?? `Term ${idx + 1}`
|
||||
}
|
||||
@@ -287,8 +512,17 @@ export class TabApp {
|
||||
this.notify(entry)
|
||||
}
|
||||
},
|
||||
// B2: telemetry is the single source of truth on the session; just re-render.
|
||||
onTelemetry: () => this.refreshTab(entry),
|
||||
})
|
||||
entry = { session, customTitle, autoTitle: null, hasActivity: false, el: null }
|
||||
entry = {
|
||||
session,
|
||||
customTitle,
|
||||
autoTitle: null,
|
||||
hasActivity: false,
|
||||
el: null,
|
||||
timelineHandle: null,
|
||||
}
|
||||
this.paneHost.appendChild(session.el)
|
||||
this.tabs.push(entry)
|
||||
session.applyTheme(THEMES[this.settings.theme] ?? THEMES['dark']!, this.settings.fontSize)
|
||||
@@ -319,10 +553,13 @@ export class TabApp {
|
||||
* distinguish them (e.g. "web-terminal #2"). Mirrors newTabForResume in
|
||||
* structure: addEntry → persist → rebuild → activate.
|
||||
*/
|
||||
openProject(repoPath: string, repoName: string, cmd = 'claude\r'): void {
|
||||
openProject(repoPath: string, repoName: string, cmd = 'claude\r', mode?: PermissionMode): void {
|
||||
const n = this.countOpenWithTitlePrefix(repoName)
|
||||
const label = n === 0 ? repoName : `${repoName} #${n + 1}`
|
||||
this.addEntry(null, label, repoPath || undefined, cmd)
|
||||
// B4-FR2: when a permission mode is chosen, upgrade the launch command to
|
||||
// `claude --permission-mode <mode>` (non-default only); 'auto' is gated (M5).
|
||||
const effectiveCmd = mode !== undefined ? this.buildClaudeCmd(this.resolveMode(mode)) : cmd
|
||||
this.addEntry(null, label, repoPath || undefined, effectiveCmd)
|
||||
this.persist()
|
||||
this.rebuild()
|
||||
this.activate(this.tabs.length - 1)
|
||||
@@ -346,12 +583,14 @@ export class TabApp {
|
||||
this.tabs.forEach((t) => this.refreshTab(t)) // in-place class/text update, no rebuild
|
||||
this.updateHomeView() // hide the seg control + home panels now a tab is active
|
||||
this.updateApprovalBar()
|
||||
if (this.timelineOpen) this.openTimelineForActive() // A4: follow the active session
|
||||
this.persist()
|
||||
}
|
||||
|
||||
closeTab(i: number): void {
|
||||
if (i < 0 || i >= this.tabs.length) return
|
||||
const [entry] = this.tabs.splice(i, 1)
|
||||
entry?.timelineHandle?.dispose() // A4: stop polling for the closed tab
|
||||
entry?.session.dispose()
|
||||
if (this.editingIndex === i) this.editingIndex = -1
|
||||
if (this.tabs.length === 0) {
|
||||
@@ -464,7 +703,9 @@ export class TabApp {
|
||||
|
||||
/* ── rendering ───────────────────────────────────────────────────── */
|
||||
|
||||
/** In-place update of one tab's classes/label/dot — never destroys the DOM. */
|
||||
/** In-place update of one tab's classes/label/dot/gauge (never destroys DOM).
|
||||
* Single `refreshTab` owner for status dot, stuck badge (A5) and telemetry
|
||||
* gauge (B2), per SP10/M4. */
|
||||
private refreshTab(entry: TabEntry): void {
|
||||
const el = entry.el
|
||||
if (!el) return
|
||||
@@ -473,6 +714,7 @@ export class TabApp {
|
||||
el.classList.toggle('unread', entry.hasActivity && idx !== this.activeIndex)
|
||||
const cs = entry.session.claudeStatus
|
||||
el.classList.toggle('claude-waiting', cs === 'waiting' && idx !== this.activeIndex)
|
||||
el.classList.toggle('claude-stuck', cs === 'stuck' && idx !== this.activeIndex) // A5
|
||||
const title = this.displayTitle(entry, idx)
|
||||
el.title = title
|
||||
const dot = el.querySelector('.tab-dot')
|
||||
@@ -480,10 +722,9 @@ export class TabApp {
|
||||
const label = el.querySelector('.tab-label')
|
||||
if (label) label.textContent = title
|
||||
const claude = el.querySelector('.tab-claude')
|
||||
if (claude) {
|
||||
claude.textContent =
|
||||
cs === 'working' ? '⚙' : cs === 'waiting' ? '⏳' : cs === 'idle' ? '✓' : ''
|
||||
}
|
||||
if (claude) claude.textContent = claudeIcon(cs)
|
||||
const gauge = el.querySelector<HTMLElement>('.tab-gauge')
|
||||
if (gauge) renderTelemetryGauge(gauge, entry.session.telemetry, STATUSLINE_TTL_MS) // B2
|
||||
}
|
||||
|
||||
/** Full rebuild — ONLY for structural changes (add/close/reorder/rename). */
|
||||
@@ -577,6 +818,11 @@ export class TabApp {
|
||||
claude.className = 'tab-claude'
|
||||
tabEl.appendChild(claude)
|
||||
|
||||
// B2: per-tab telemetry gauge container (filled in by refreshTab).
|
||||
const gauge = document.createElement('span')
|
||||
gauge.className = 'tab-gauge'
|
||||
tabEl.appendChild(gauge)
|
||||
|
||||
const close = document.createElement('button')
|
||||
close.className = 'tab-close'
|
||||
close.textContent = '×'
|
||||
@@ -603,6 +849,19 @@ export class TabApp {
|
||||
add.addEventListener('click', () => this.newTab())
|
||||
this.tabBar.appendChild(add)
|
||||
|
||||
// A4 timeline toggle + A1 push bell — re-appended each rebuild so they
|
||||
// survive tabBar.replaceChildren() (the bell's subtree is mounted once).
|
||||
const tl = document.createElement('button')
|
||||
tl.className = 'tab-timeline'
|
||||
tl.textContent = '📜'
|
||||
tl.title = 'Activity timeline'
|
||||
tl.setAttribute('aria-label', 'Activity timeline')
|
||||
tl.classList.toggle('active', this.timelineOpen)
|
||||
tl.addEventListener('click', () => this.toggleTimeline())
|
||||
this.timelineBtn = tl
|
||||
this.tabBar.appendChild(tl)
|
||||
this.tabBar.appendChild(this.pushHost)
|
||||
|
||||
// Show/hide the home screen based on whether any tab is open.
|
||||
this.updateHomeView()
|
||||
}
|
||||
|
||||
@@ -13,7 +13,14 @@ import type { ITheme } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { SearchAddon } from '@xterm/addon-search'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import type { ClaudeStatus, ClientMessage, ServerMessage } from '../src/types.js'
|
||||
import type {
|
||||
ClaudeStatus,
|
||||
ClientMessage,
|
||||
PermissionGate,
|
||||
PermissionMode,
|
||||
ServerMessage,
|
||||
StatusTelemetry,
|
||||
} from '../src/types.js'
|
||||
import { folderFromTitle, cwdFromOsc7 } from './title-util.js'
|
||||
|
||||
// Delay after the shell is ready before typing a session's initial command
|
||||
@@ -56,6 +63,9 @@ export interface TerminalSessionOpts {
|
||||
onStatus?: (status: SessionStatus) => void
|
||||
/** Optional: fired when Claude Code activity changes (from a hook, H2). */
|
||||
onClaudeStatus?: (status: ClaudeStatus, detail?: string) => void
|
||||
/** Optional: fired when new statusLine telemetry arrives (B2). Single source of
|
||||
* truth — T-tabs reads session.telemetry via the getter, not its own copy. */
|
||||
onTelemetry?: (telemetry: StatusTelemetry) => void
|
||||
/** Optional: spawn a NEW session in this directory ("new tab here", M6). */
|
||||
cwd?: string
|
||||
/** Optional: type this once the new session's shell is ready (O2 resume). */
|
||||
@@ -74,6 +84,7 @@ export class TerminalSession {
|
||||
private readonly onTitle: ((title: string) => void) | undefined
|
||||
private readonly onStatus: ((status: SessionStatus) => void) | undefined
|
||||
private readonly onClaudeStatus: ((status: ClaudeStatus, detail?: string) => void) | undefined
|
||||
private readonly onTelemetry: ((telemetry: StatusTelemetry) => void) | undefined
|
||||
private readonly spawnCwd: string | undefined
|
||||
private readonly initialInput: string | undefined
|
||||
private initialSent = false
|
||||
@@ -82,6 +93,8 @@ export class TerminalSession {
|
||||
private cwdValue: string | null = null
|
||||
private pendingApprovalValue = false
|
||||
private pendingToolValue: string | undefined = undefined
|
||||
private telemetryValue: StatusTelemetry | null = null
|
||||
private pendingGateValue: PermissionGate | null = null
|
||||
|
||||
private ws: WebSocket | null = null
|
||||
private sessionId: string | null
|
||||
@@ -102,6 +115,7 @@ export class TerminalSession {
|
||||
this.onTitle = opts.onTitle
|
||||
this.onStatus = opts.onStatus
|
||||
this.onClaudeStatus = opts.onClaudeStatus
|
||||
this.onTelemetry = opts.onTelemetry
|
||||
this.spawnCwd = opts.cwd
|
||||
this.initialInput = opts.initialInput
|
||||
|
||||
@@ -163,6 +177,18 @@ export class TerminalSession {
|
||||
return this.pendingToolValue
|
||||
}
|
||||
|
||||
/** Latest statusLine telemetry (B2). Single source of truth (review #15):
|
||||
* T-tabs reads this getter; TabEntry does NOT cache its own copy. */
|
||||
get telemetry(): StatusTelemetry | null {
|
||||
return this.telemetryValue
|
||||
}
|
||||
|
||||
/** The gate kind from the last pending status frame (B4): 'plan' or 'tool'.
|
||||
* Null when no approval is held or the last status had no gate. */
|
||||
get pendingGate(): PermissionGate | null {
|
||||
return this.pendingGateValue
|
||||
}
|
||||
|
||||
private setStatus(s: SessionStatus): void {
|
||||
this.statusValue = s
|
||||
this.onStatus?.(s)
|
||||
@@ -270,9 +296,15 @@ export class TerminalSession {
|
||||
this.claudeStatusValue = msg.status
|
||||
this.pendingApprovalValue = msg.pending === true
|
||||
this.pendingToolValue = msg.pending === true ? msg.detail : undefined
|
||||
this.pendingGateValue = msg.gate ?? null
|
||||
this.onClaudeStatus?.(msg.status, msg.detail)
|
||||
break
|
||||
}
|
||||
case 'telemetry': {
|
||||
this.telemetryValue = msg.telemetry
|
||||
this.onTelemetry?.(msg.telemetry)
|
||||
break
|
||||
}
|
||||
case 'exit': {
|
||||
this.setStatus('exited')
|
||||
const reason = msg.reason ? ` (${msg.reason})` : ''
|
||||
@@ -294,6 +326,14 @@ export class TerminalSession {
|
||||
})
|
||||
break
|
||||
}
|
||||
default: {
|
||||
// Compile-time exhaustiveness: TypeScript narrows msg to 'never' here
|
||||
// once all ServerMessage variants are handled. Silently ignored at runtime
|
||||
// (future server additions won't crash older clients).
|
||||
const _exhaustive: never = msg
|
||||
void _exhaustive
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,10 +358,12 @@ export class TerminalSession {
|
||||
this.sendMsg({ type: 'input', data })
|
||||
}
|
||||
|
||||
/** Resolve a held PermissionRequest (H3). */
|
||||
approve(): void {
|
||||
/** Resolve a held PermissionRequest (H3). Optionally relay a permission-mode
|
||||
* change (B4): mode is included only when explicitly provided so the server
|
||||
* can distinguish "approve with default" from "approve, keep existing mode". */
|
||||
approve(mode?: PermissionMode): void {
|
||||
this.pendingApprovalValue = false
|
||||
this.sendMsg({ type: 'approve' })
|
||||
this.sendMsg({ type: 'approve', ...(mode !== undefined ? { mode } : {}) })
|
||||
}
|
||||
reject(): void {
|
||||
this.pendingApprovalValue = false
|
||||
|
||||
226
public/timeline.ts
Normal file
226
public/timeline.ts
Normal 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
179
public/voice.ts
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,14 @@
|
||||
* $WEBTERM_SESSION (which tab). Outside web-terminal those vars are unset, so
|
||||
* curl no-ops (|| true) — the hooks are harmless in normal terminals.
|
||||
*
|
||||
* v0.7 additions (T-hooks-installer):
|
||||
* H1 PostToolUse added to FF_EVENTS (A4 timeline completeness, AC-A4.5)
|
||||
* L5 --max-time computed from PERM_TIMEOUT_MS env (not hardcoded 350, SEC-M4)
|
||||
* H3 ntfy/Pushover bridge: only installed when WEBTERM_NTFY_URL+TOPIC set;
|
||||
* token always via $WEBTERM_NTFY_TOKEN env — never a literal (SEC-C6)
|
||||
* B2 statusLine handler: scripts/statusline.mjs installed idempotently;
|
||||
* --remove cleans the entry; user's own statusLine is not overwritten
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/setup-hooks.mjs # install (idempotent)
|
||||
* node scripts/setup-hooks.mjs --remove # uninstall
|
||||
@@ -19,69 +27,277 @@
|
||||
import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const SETTINGS = path.join(homedir(), '.claude', 'settings.json')
|
||||
const MARKER = 'WEBTERM_HOOK_URL' // identifies our hook entries
|
||||
// ── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Fire-and-forget status events: POST and discard the response.
|
||||
const FF_COMMAND =
|
||||
/** Appears in every web-terminal hook command — used by cleanup to identify our entries. */
|
||||
export const MARKER = 'WEBTERM_HOOK_URL'
|
||||
|
||||
/** Appears in our statusLine command — used to detect and remove our entry idempotently. */
|
||||
export const STATUSLINE_MARKER = 'statusline.mjs'
|
||||
|
||||
/** Safety buffer added to PERM_TIMEOUT_MS/1000 for --max-time (L5/SEC-M4). */
|
||||
const CURL_BUFFER_SEC = 5
|
||||
|
||||
/** Default value for PERM_TIMEOUT_MS when env is unset or invalid (5 minutes in ms). */
|
||||
const DEFAULT_PERM_TIMEOUT_MS = 300_000
|
||||
|
||||
// ── Pure helpers (exported for unit tests) ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute the --max-time value (seconds) for the held PermissionRequest curl.
|
||||
* Result is always > permTimeoutMs/1000 so curl never races the server (L5/SEC-M4).
|
||||
* Falls back to DEFAULT_PERM_TIMEOUT_MS when the input is missing, zero, or negative.
|
||||
*
|
||||
* @param {number} [permTimeoutMs]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function computeMaxTimeSec(permTimeoutMs = DEFAULT_PERM_TIMEOUT_MS) {
|
||||
const safeMs =
|
||||
Number.isFinite(permTimeoutMs) && permTimeoutMs > 0 ? permTimeoutMs : DEFAULT_PERM_TIMEOUT_MS
|
||||
return Math.ceil(safeMs / 1000) + CURL_BUFFER_SEC
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the held PermissionRequest curl command string.
|
||||
* --max-time is supplied by the caller (from computeMaxTimeSec) — never hardcoded (L5).
|
||||
*
|
||||
* @param {number} maxTimeSec
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildPermCommand(maxTimeSec) {
|
||||
return (
|
||||
'curl -s --max-time ' +
|
||||
maxTimeSec +
|
||||
' -X POST "${WEBTERM_HOOK_URL}/permission"' +
|
||||
' -H "X-Webterm-Session: $WEBTERM_SESSION" -H "Content-Type: application/json" --data-binary @-'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fire-and-forget ntfy notification curl command (H3).
|
||||
*
|
||||
* Security guarantees (SEC-C6):
|
||||
* - $WEBTERM_NTFY_TOKEN is a shell variable reference, NEVER a literal value.
|
||||
* - The guard `[ -n "$WEBTERM_HOOK_URL" ]` makes this a no-op outside web-terminal
|
||||
* (WEBTERM_HOOK_URL is injected by the server spawn, not available globally).
|
||||
* - Because the command contains MARKER (WEBTERM_HOOK_URL), the existing cleanup
|
||||
* loop removes it correctly on --remove / re-install.
|
||||
*
|
||||
* @param {'high' | 'low'} priority - ntfy Priority header value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildNtfyCommand(priority) {
|
||||
return (
|
||||
'[ -n "$WEBTERM_HOOK_URL" ] && ' +
|
||||
'curl -s -X POST "${WEBTERM_NTFY_URL}/${WEBTERM_NTFY_TOPIC}"' +
|
||||
' -H "Authorization: Bearer $WEBTERM_NTFY_TOKEN"' +
|
||||
' -H "Priority: ' +
|
||||
priority +
|
||||
'"' +
|
||||
' >/dev/null 2>&1 || true'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the statusLine command Claude Code runs on each status update (B2).
|
||||
* Uses an absolute path so it works regardless of the shell's CWD.
|
||||
*
|
||||
* @param {string} scriptDir - directory that contains statusline.mjs
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildStatusLineCommand(scriptDir) {
|
||||
return 'node ' + path.join(scriptDir, 'statusline.mjs')
|
||||
}
|
||||
|
||||
// ── Hook event lists ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fire-and-forget hook events.
|
||||
* H1: PostToolUse added so the A4 timeline captures "task complete" events
|
||||
* (AC-A4.5, A4-FR1). These all POST asynchronously; Claude doesn't wait.
|
||||
*/
|
||||
export const FF_EVENTS = [
|
||||
'UserPromptSubmit',
|
||||
'PreToolUse',
|
||||
'PostToolUse', // H1 — added for A4 timeline completeness
|
||||
'Notification',
|
||||
'Stop',
|
||||
'SessionEnd',
|
||||
]
|
||||
|
||||
/** Fire-and-forget: POST hook body to web-terminal, discard response. */
|
||||
export const FF_COMMAND =
|
||||
'curl -s -X POST "$WEBTERM_HOOK_URL" -H "X-Webterm-Session: $WEBTERM_SESSION"' +
|
||||
' -H "Content-Type: application/json" --data-binary @- >/dev/null 2>&1 || true'
|
||||
const FF_EVENTS = ['UserPromptSubmit', 'PreToolUse', 'Notification', 'Stop', 'SessionEnd']
|
||||
|
||||
// PermissionRequest is HELD: curl waits for the server's response (the decision
|
||||
// JSON, produced when you tap Approve/Reject) and writes it to stdout for Claude.
|
||||
// Empty/failed (outside web-terminal, or timeout) → Claude shows its own prompt.
|
||||
const PERM_COMMAND =
|
||||
'curl -s --max-time 350 -X POST "${WEBTERM_HOOK_URL}/permission"' +
|
||||
' -H "X-Webterm-Session: $WEBTERM_SESSION" -H "Content-Type: application/json" --data-binary @-'
|
||||
/** Held PermissionRequest command — computed at module load from PERM_TIMEOUT_MS env. */
|
||||
export const PERM_COMMAND = buildPermCommand(
|
||||
computeMaxTimeSec(
|
||||
(() => {
|
||||
const raw = process.env['PERM_TIMEOUT_MS']
|
||||
if (raw === undefined) return DEFAULT_PERM_TIMEOUT_MS
|
||||
const n = Number(raw)
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_PERM_TIMEOUT_MS
|
||||
})(),
|
||||
),
|
||||
)
|
||||
|
||||
const remove = process.argv.includes('--remove')
|
||||
// ── Core install/remove logic (exported for tests) ───────────────────────────
|
||||
|
||||
let settings = {}
|
||||
if (existsSync(SETTINGS)) {
|
||||
/**
|
||||
* Apply (install or remove) web-terminal hooks to a settings.json file.
|
||||
* This is the testable core — the main script entry calls it with real env values.
|
||||
*
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.settingsPath - absolute path to settings.json
|
||||
* @param {boolean} opts.remove - true = remove our entries, false = install
|
||||
* @param {boolean} opts.hasNtfy - true = add ntfy bridge hook commands (H3)
|
||||
* @param {number} opts.permTimeoutMs - used to compute --max-time (L5/SEC-M4)
|
||||
* @param {string} opts.scriptDir - directory of statusline.mjs (for absolute path)
|
||||
* @returns {{ ok: boolean; error?: string; settings?: object }}
|
||||
*/
|
||||
export function applyHooks({ settingsPath, remove, hasNtfy, permTimeoutMs, scriptDir }) {
|
||||
const maxTimeSec = computeMaxTimeSec(permTimeoutMs)
|
||||
const permCommand = buildPermCommand(maxTimeSec)
|
||||
const statusLineCommand = buildStatusLineCommand(scriptDir)
|
||||
|
||||
/** @type {Record<string, unknown>} */
|
||||
let settings = {}
|
||||
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
settings = JSON.parse(readFileSync(SETTINGS, 'utf8'))
|
||||
settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
|
||||
} catch {
|
||||
console.error(`${SETTINGS} is not valid JSON — aborting (fix it first).`)
|
||||
process.exit(1)
|
||||
return { ok: false, error: `${settingsPath} is not valid JSON — aborting` }
|
||||
}
|
||||
copyFileSync(settingsPath, `${settingsPath}.webterm-bak`)
|
||||
}
|
||||
copyFileSync(SETTINGS, `${SETTINGS}.webterm-bak`)
|
||||
}
|
||||
|
||||
if (typeof settings.hooks !== 'object' || settings.hooks === null) settings.hooks = {}
|
||||
if (typeof settings['hooks'] !== 'object' || settings['hooks'] === null) {
|
||||
settings['hooks'] = {}
|
||||
}
|
||||
|
||||
// Strip any existing web-terminal hook groups (makes install idempotent + powers --remove).
|
||||
for (const ev of Object.keys(settings.hooks)) {
|
||||
const groups = settings.hooks[ev]
|
||||
/** @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
|
||||
settings.hooks[ev] = groups.filter(
|
||||
hooks[ev] = groups.filter(
|
||||
(g) =>
|
||||
!(
|
||||
g &&
|
||||
Array.isArray(g.hooks) &&
|
||||
g.hooks.some((h) => typeof h?.command === 'string' && h.command.includes(MARKER))
|
||||
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 (settings.hooks[ev].length === 0) delete settings.hooks[ev]
|
||||
}
|
||||
|
||||
if (!remove) {
|
||||
for (const ev of FF_EVENTS) {
|
||||
if (!Array.isArray(settings.hooks[ev])) settings.hooks[ev] = []
|
||||
settings.hooks[ev].push({ hooks: [{ type: 'command', command: FF_COMMAND }] })
|
||||
if (hooks[ev].length === 0) delete hooks[ev]
|
||||
}
|
||||
if (!Array.isArray(settings.hooks['PermissionRequest'])) settings.hooks['PermissionRequest'] = []
|
||||
settings.hooks['PermissionRequest'].push({ hooks: [{ type: 'command', command: PERM_COMMAND }] })
|
||||
|
||||
// ── 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 }
|
||||
}
|
||||
|
||||
mkdirSync(path.dirname(SETTINGS), { recursive: true })
|
||||
writeFileSync(SETTINGS, `${JSON.stringify(settings, null, 2)}\n`)
|
||||
// ── Main script entry (runs only when executed directly, not when imported) ──
|
||||
|
||||
console.log(
|
||||
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)
|
||||
}
|
||||
|
||||
const allEvents = [...FF_EVENTS, 'PermissionRequest']
|
||||
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.')
|
||||
: `Installed web-terminal hooks into ${SETTINGS} (events: ${allEvents.join(', ')})`,
|
||||
)
|
||||
|
||||
if (!remove) {
|
||||
console.log(`Installed statusLine handler: ${buildStatusLineCommand(SCRIPT_DIR)}`)
|
||||
if (hasNtfy) {
|
||||
console.log(`Installed ntfy bridge (NEEDS-INPUT=high, DONE=low) → ${ntfyUrl}/${ntfyTopic}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (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
186
scripts/statusline.mjs
Executable 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)
|
||||
}
|
||||
})()
|
||||
}
|
||||
176
src/config.ts
176
src/config.ts
@@ -1,14 +1,26 @@
|
||||
/**
|
||||
* src/config.ts — T4: environment config loader
|
||||
* src/config.ts — T4 / T-config: environment config loader
|
||||
*
|
||||
* Reads env vars once at startup, validates them, derives allowedOrigins from
|
||||
* local NIC IPs (M1 — NOT from bindHost; 0.0.0.0 is never a valid Origin),
|
||||
* and returns a frozen Config object.
|
||||
*
|
||||
* v0.7 adds 21 new env vars for the Walk-away Workbench features (A1–A5, B1–B4).
|
||||
* The returned object is a superset of Config (all existing fields + 21 new v0.7
|
||||
* fields). Callers that use `const cfg = loadConfig(env)` (no explicit `: Config`
|
||||
* annotation) get the full type with all new fields accessible. Callers that
|
||||
* narrow to `Config` (e.g. function params typed as Config) still work because
|
||||
* the wider type is structurally assignable to Config.
|
||||
*
|
||||
* NOTE(T-config): The Config interface in src/types.ts should be extended with the
|
||||
* 21 new fields listed below so downstream tasks (T-server-wire, N-push, etc.) can
|
||||
* access them type-safely via the Config type. This is a coordination point for the
|
||||
* orchestrator — the impl is here, the interface declaration belongs in T-types.
|
||||
*/
|
||||
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import type { Config, EnvLike } from './types.js'
|
||||
import type { Config, EnvLike, PermissionMode } from './types.js'
|
||||
import { tmuxAvailable } from './session/tmux.js'
|
||||
|
||||
/** USE_TMUX: '1'/'true'/'on' → on, '0'/'false'/'off' → off, else auto-detect tmux. */
|
||||
@@ -36,6 +48,25 @@ const DEFAULT_PROJECT_SCAN_DEPTH = 4 // v0.6: how deep to scan roots for .git
|
||||
const DEFAULT_PROJECT_SCAN_TTL_MS = 10_000 // v0.6: /projects discovery cache TTL
|
||||
const DEFAULT_EDITOR_CMD = 'code' // v0.6: POST /open-in-editor launches this on the host
|
||||
|
||||
// v0.7 Walk-away Workbench defaults
|
||||
// A1 push notifications
|
||||
const DEFAULT_PUSH_MAX_SUBS = 50
|
||||
// A4 timeline
|
||||
const DEFAULT_TIMELINE_MAX = 200
|
||||
// A5 stuck detection
|
||||
const DEFAULT_STUCK_TTL_SEC = 600 // 10 minutes (env var in seconds)
|
||||
// B1 git diff
|
||||
const DEFAULT_DIFF_TIMEOUT_MS = 2_000
|
||||
const DEFAULT_DIFF_MAX_BYTES = 2 * 1024 * 1024 // 2 MB
|
||||
const DEFAULT_DIFF_MAX_FILES = 300
|
||||
// B2 statusline telemetry
|
||||
const DEFAULT_STATUSLINE_TTL_MS = 30_000
|
||||
// B3 worktrees
|
||||
const DEFAULT_WORKTREE_TIMEOUT_MS = 10_000
|
||||
|
||||
/** Valid --permission-mode values (R0-confirmed). Whitelist for validation. */
|
||||
const PERMISSION_MODES: readonly PermissionMode[] = ['default', 'acceptEdits', 'plan', 'auto']
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Parse a non-negative integer env value (0 allowed), or the fallback when unset. */
|
||||
@@ -63,6 +94,31 @@ function parseBool(raw: string | undefined, fallback: boolean): boolean {
|
||||
return fallback
|
||||
}
|
||||
|
||||
/** Parse a seconds-valued env var and return the equivalent milliseconds, or fallback (ms). */
|
||||
function parseSecondsToMs(
|
||||
raw: string | undefined,
|
||||
label: string,
|
||||
fallbackMs: number,
|
||||
): number {
|
||||
if (raw === undefined) return fallbackMs
|
||||
const n = Number(raw)
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
|
||||
throw new Error(
|
||||
`Invalid config: ${label}=${JSON.stringify(raw)} — must be a non-negative integer (seconds)`,
|
||||
)
|
||||
}
|
||||
return n * 1000
|
||||
}
|
||||
|
||||
/** Parse and validate a PermissionMode env var; throws for unknown values (including empty string). */
|
||||
function parsePermissionMode(raw: string | undefined, label: string): PermissionMode {
|
||||
if (raw === undefined) return 'default'
|
||||
if ((PERMISSION_MODES as readonly string[]).includes(raw)) return raw as PermissionMode
|
||||
throw new Error(
|
||||
`Invalid config: ${label}=${JSON.stringify(raw)} — must be one of ${PERMISSION_MODES.join('|')}`,
|
||||
)
|
||||
}
|
||||
|
||||
/** Parse PROJECT_ROOTS (comma-separated absolute paths); expand a leading '~' to homeDir.
|
||||
* Empty / unset → [homeDir]. */
|
||||
function parseProjectRoots(raw: string | undefined, homeDir: string): readonly string[] {
|
||||
@@ -176,7 +232,15 @@ function deriveAllowedOrigins(port: number, extraEnv: string | undefined): reado
|
||||
* Invalid values (port out of range, TTL non-integer, etc.) throw immediately
|
||||
* so the server never starts in a misconfigured state (fail-fast).
|
||||
*
|
||||
* The returned Config is Object.freeze'd — immutable after construction.
|
||||
* The returned object is Object.freeze'd — immutable after construction. It
|
||||
* contains all existing Config fields plus 21 new v0.7 Walk-away Workbench
|
||||
* fields. The return type is inferred (wider than Config) so callers can access
|
||||
* the new fields without explicit type assertions. Functions accepting Config
|
||||
* still work because the wider type is structurally assignable to Config.
|
||||
*
|
||||
* L5 (SEC-M4): permTimeoutMs must be > 0 — it is used to compute the
|
||||
* --max-time flag in setup-hooks.mjs (T-hooks-installer). Zero would make
|
||||
* --max-time=0 which disables the curl timeout entirely.
|
||||
*/
|
||||
export function loadConfig(env: EnvLike): Config {
|
||||
const port = parsePort(env['PORT'])
|
||||
@@ -218,6 +282,13 @@ export function loadConfig(env: EnvLike): Config {
|
||||
DEFAULT_PERM_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
// L5 / SEC-M4: permTimeoutMs must be > 0 (T-hooks-installer uses it for --max-time)
|
||||
if (permTimeoutMs === 0) {
|
||||
throw new Error(
|
||||
'Invalid config: PERM_TIMEOUT_MS must be > 0 (used to calculate --max-time for setup-hooks)',
|
||||
)
|
||||
}
|
||||
|
||||
const reapIntervalMs = parseNonNegativeInt(
|
||||
env['REAP_INTERVAL_MS'],
|
||||
'REAP_INTERVAL_MS',
|
||||
@@ -245,7 +316,76 @@ export function loadConfig(env: EnvLike): Config {
|
||||
const projectDirtyCheck = parseBool(env['PROJECT_DIRTY_CHECK'], true)
|
||||
const editorCmd = env['EDITOR_CMD']?.trim() || DEFAULT_EDITOR_CMD
|
||||
|
||||
return Object.freeze({
|
||||
// ── v0.7 Walk-away Workbench — 21 new env vars ─────────────────────────────
|
||||
|
||||
// A1 push notifications (SEC-C5: vapidPrivateKey never logged by this module)
|
||||
const vapidPublicKey = env['VAPID_PUBLIC_KEY'] || undefined
|
||||
const vapidPrivateKey = env['VAPID_PRIVATE_KEY'] || undefined // SECRET — never log
|
||||
const vapidSubject = env['VAPID_SUBJECT'] || undefined
|
||||
const pushStorePath =
|
||||
env['PUSH_STORE_PATH'] || path.join(homeDir, '.web-terminal-push-subs.json')
|
||||
const pushMaxSubs = parseNonNegativeInt(env['PUSH_MAX_SUBS'], 'PUSH_MAX_SUBS', DEFAULT_PUSH_MAX_SUBS)
|
||||
const notifyDone = parseBool(env['NOTIFY_DONE'], true) // default on
|
||||
const notifyDnd = parseBool(env['NOTIFY_DND'], false) // default off (do not disturb)
|
||||
// DECISION_TOKEN_TTL_MS defaults to permTimeoutMs (parsed after it, order matters)
|
||||
const decisionTokenTtlMs = parseNonNegativeInt(
|
||||
env['DECISION_TOKEN_TTL_MS'],
|
||||
'DECISION_TOKEN_TTL_MS',
|
||||
permTimeoutMs,
|
||||
)
|
||||
|
||||
// A4 activity timeline
|
||||
const timelineMax = parseNonNegativeInt(env['TIMELINE_MAX'], 'TIMELINE_MAX', DEFAULT_TIMELINE_MAX)
|
||||
const timelineEnabled = parseBool(env['TIMELINE_ENABLED'], true)
|
||||
|
||||
// A5 stuck detection (STUCK_TTL env var is in seconds; stored in ms)
|
||||
const stuckTtlMs = parseSecondsToMs(env['STUCK_TTL'], 'STUCK_TTL', DEFAULT_STUCK_TTL_SEC * 1000)
|
||||
const stuckAlert = parseBool(env['STUCK_ALERT'], true)
|
||||
|
||||
// B1 git diff viewer
|
||||
const diffTimeoutMs = parseNonNegativeInt(
|
||||
env['DIFF_TIMEOUT_MS'],
|
||||
'DIFF_TIMEOUT_MS',
|
||||
DEFAULT_DIFF_TIMEOUT_MS,
|
||||
)
|
||||
const diffMaxBytes = parseNonNegativeInt(
|
||||
env['DIFF_MAX_BYTES'],
|
||||
'DIFF_MAX_BYTES',
|
||||
DEFAULT_DIFF_MAX_BYTES,
|
||||
)
|
||||
const diffMaxFiles = parseNonNegativeInt(
|
||||
env['DIFF_MAX_FILES'],
|
||||
'DIFF_MAX_FILES',
|
||||
DEFAULT_DIFF_MAX_FILES,
|
||||
)
|
||||
|
||||
// B2 statusLine telemetry
|
||||
const statuslineTtlMs = parseNonNegativeInt(
|
||||
env['STATUSLINE_TTL_MS'],
|
||||
'STATUSLINE_TTL_MS',
|
||||
DEFAULT_STATUSLINE_TTL_MS,
|
||||
)
|
||||
|
||||
// B3 git worktree creation
|
||||
const worktreeEnabled = parseBool(env['WORKTREE_ENABLED'], true)
|
||||
const worktreeRoot = env['WORKTREE_ROOT'] || undefined // undefined → computed at creation time
|
||||
const worktreeTimeoutMs = parseNonNegativeInt(
|
||||
env['WORKTREE_TIMEOUT_MS'],
|
||||
'WORKTREE_TIMEOUT_MS',
|
||||
DEFAULT_WORKTREE_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
// B4 permission mode relay
|
||||
const defaultPermissionMode = parsePermissionMode(
|
||||
env['DEFAULT_PERMISSION_MODE'],
|
||||
'DEFAULT_PERMISSION_MODE',
|
||||
)
|
||||
const allowAutoMode = parseBool(env['ALLOW_AUTO_MODE'], false) // default off (SEC-M5)
|
||||
|
||||
// ── assemble + freeze ───────────────────────────────────────────────────────
|
||||
// `satisfies Config` on the base object verifies all existing Config fields
|
||||
// are present without triggering excess-property checks on the v0.7 additions.
|
||||
const base = {
|
||||
port,
|
||||
bindHost,
|
||||
shellPath,
|
||||
@@ -266,5 +406,31 @@ export function loadConfig(env: EnvLike): Config {
|
||||
projectScanTtlMs,
|
||||
projectDirtyCheck,
|
||||
editorCmd,
|
||||
} satisfies Config)
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
...base,
|
||||
// v0.7 additions (21 new fields)
|
||||
vapidPublicKey,
|
||||
vapidPrivateKey,
|
||||
vapidSubject,
|
||||
pushStorePath,
|
||||
pushMaxSubs,
|
||||
notifyDone,
|
||||
notifyDnd,
|
||||
decisionTokenTtlMs,
|
||||
timelineMax,
|
||||
timelineEnabled,
|
||||
stuckTtlMs,
|
||||
stuckAlert,
|
||||
diffTimeoutMs,
|
||||
diffMaxBytes,
|
||||
diffMaxFiles,
|
||||
statuslineTtlMs,
|
||||
worktreeEnabled,
|
||||
worktreeRoot,
|
||||
worktreeTimeoutMs,
|
||||
defaultPermissionMode,
|
||||
allowAutoMode,
|
||||
})
|
||||
}
|
||||
|
||||
365
src/http/diff.ts
Normal file
365
src/http/diff.ts
Normal 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 }
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
/**
|
||||
* src/http/hook.ts (H2) — map a Claude Code hook POST to a session status update.
|
||||
*
|
||||
* Pure + never throws. The web-terminal sessionId arrives in the
|
||||
* Pure + never throws (SEC-M7). The web-terminal sessionId arrives in the
|
||||
* `X-Webterm-Session` header (injected as $WEBTERM_SESSION on spawn); the hook
|
||||
* payload arrives as JSON in the body. We translate the event into a coarse
|
||||
* ClaudeStatus the UI can show. The server is still a byte-shuttle for the
|
||||
* terminal stream — this is an out-of-band side-channel.
|
||||
*
|
||||
* T-hook-intake (v0.7): parseHookEvent now returns HookEventFull, which adds
|
||||
* `at` (server timestamp), `eventClass` (raw hook name), `toolInput` (raw
|
||||
* passthrough), and `gate` ('plan' | 'tool') for permission-mode relay (B4).
|
||||
*/
|
||||
|
||||
import type { ClaudeStatus } from '../types.js';
|
||||
import type { ClaudeStatus, PermissionGate } from '../types.js';
|
||||
|
||||
/** Base shape kept for backward compat; new callers should use HookEventFull. */
|
||||
export interface HookEvent {
|
||||
sessionId: string;
|
||||
status: ClaudeStatus;
|
||||
@@ -17,16 +22,56 @@ export interface HookEvent {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param sessionId value of the `X-Webterm-Session` header
|
||||
* @param body parsed JSON body of the hook POST (untrusted)
|
||||
* @returns a status update, or null if the payload is unusable
|
||||
* Full event shape returned by parseHookEvent (A4 / B4).
|
||||
*
|
||||
* `at` — server Date.now() at parse time (timeline anchor).
|
||||
* `eventClass` — the raw hook_event_name from the whitelist, passed as-is
|
||||
* so callers (timeline, manager) can make semantic decisions
|
||||
* without re-parsing.
|
||||
* `toolInput` — raw tool_input value from the JSON body, unknown type,
|
||||
* passed through verbatim for timeline / future use.
|
||||
* `gate` — 'plan' for an ExitPlanMode PermissionRequest (three-way
|
||||
* approve / auto / keep-planning); 'tool' for any other
|
||||
* PermissionRequest; absent for all other events.
|
||||
*/
|
||||
export function parseHookEvent(sessionId: string | undefined, body: unknown): HookEvent | null {
|
||||
export interface HookEventFull extends HookEvent {
|
||||
at: number;
|
||||
eventClass: string;
|
||||
toolInput?: unknown;
|
||||
gate?: PermissionGate;
|
||||
}
|
||||
|
||||
/** Whitelist of hook_event_name values we recognise. Unknown names → null. */
|
||||
const ALLOWED_EVENTS = new Set([
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'UserPromptSubmit',
|
||||
'PermissionRequest',
|
||||
'Stop',
|
||||
'SessionEnd',
|
||||
'Notification',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Parse a Claude Code hook POST into a HookEventFull.
|
||||
*
|
||||
* @param sessionId Value of the `X-Webterm-Session` header.
|
||||
* @param body Parsed JSON body of the hook POST (untrusted, unknown).
|
||||
* @returns A HookEventFull, or null when the payload is unusable.
|
||||
* Never throws (SEC-M7: unknown + narrowing).
|
||||
*/
|
||||
export function parseHookEvent(
|
||||
sessionId: string | undefined,
|
||||
body: unknown,
|
||||
): HookEventFull | null {
|
||||
if (typeof sessionId !== 'string' || sessionId.length === 0) return null;
|
||||
if (body === null || typeof body !== 'object') return null;
|
||||
if (body === null || typeof body !== 'object' || Array.isArray(body)) return null;
|
||||
|
||||
const b = body as Record<string, unknown>;
|
||||
const event = typeof b['hook_event_name'] === 'string' ? b['hook_event_name'] : '';
|
||||
|
||||
if (!ALLOWED_EVENTS.has(event)) return null;
|
||||
|
||||
const notif = typeof b['notification_type'] === 'string' ? b['notification_type'] : '';
|
||||
const tool = typeof b['tool_name'] === 'string' ? b['tool_name'] : undefined;
|
||||
|
||||
@@ -48,10 +93,32 @@ export function parseHookEvent(sessionId: string | undefined, body: unknown): Ho
|
||||
status = notif === 'permission_prompt' ? 'waiting' : 'idle';
|
||||
break;
|
||||
default:
|
||||
// Unreachable: ALLOWED_EVENTS guard above catches unknown names.
|
||||
return null;
|
||||
}
|
||||
|
||||
return status === 'waiting' && tool !== undefined
|
||||
? { sessionId, status, detail: tool }
|
||||
: { sessionId, status };
|
||||
// detail: only when waiting + a tool name is present.
|
||||
const detail = status === 'waiting' && tool !== undefined ? tool : undefined;
|
||||
|
||||
// toolInput: pass through verbatim when the key is present (even if null).
|
||||
const hasToolInput = 'tool_input' in b;
|
||||
const toolInput = hasToolInput ? b['tool_input'] : undefined;
|
||||
|
||||
// gate: classify the permission gate type (B4).
|
||||
const gate: PermissionGate | undefined =
|
||||
event === 'PermissionRequest'
|
||||
? tool === 'ExitPlanMode'
|
||||
? 'plan'
|
||||
: 'tool'
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
status,
|
||||
at: Date.now(),
|
||||
eventClass: event,
|
||||
...(detail !== undefined ? { detail } : {}),
|
||||
...(hasToolInput ? { toolInput } : {}),
|
||||
...(gate !== undefined ? { gate } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
131
src/http/statusline.ts
Normal file
131
src/http/statusline.ts
Normal 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 }),
|
||||
};
|
||||
}
|
||||
@@ -5,15 +5,22 @@
|
||||
* record is the main worktree. Best-effort: a non-repo / git error yields [].
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import type { WorktreeInfo } from '../types.js'
|
||||
import type { CreateWorktreeResult, WorktreeInfo } from '../types.js'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
const WORKTREE_TIMEOUT_MS = 2000
|
||||
const WORKTREE_MAX_BUFFER = 1024 * 1024
|
||||
|
||||
const MAX_BRANCH_LEN = 250
|
||||
// git ref-name forbidden punctuation subset + backslash (SEC-H2). Control chars
|
||||
// and whitespace are matched separately via \s and the \x00-\x1f\x7f range.
|
||||
const FORBIDDEN_BRANCH_CHARS = /[\x00-\x1f\x7f\s~^:?*[\\]/
|
||||
|
||||
/**
|
||||
* Parse `git worktree list --porcelain`. `currentPath` flags which worktree is
|
||||
* the requested project (isCurrent). The first record is the main worktree.
|
||||
@@ -71,3 +78,174 @@ export async function listWorktrees(repoPath: string): Promise<WorktreeInfo[]> {
|
||||
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
174
src/push/push-service.ts
Normal 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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
112
src/push/subscription-store.ts
Normal file
112
src/push/subscription-store.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
375
src/server.ts
375
src/server.ts
@@ -1,23 +1,18 @@
|
||||
/**
|
||||
* src/server.ts (T14) — HTTP + WebSocket wiring layer.
|
||||
* src/server.ts (T14 + T-server-wire) — HTTP + WebSocket wiring layer.
|
||||
*
|
||||
* Responsibilities (ARCHITECTURE §3.6):
|
||||
* 1. Express static hosting of public/ (including public/build/ esbuild output).
|
||||
* 2. WebSocketServer in noServer mode (L3) — never auto-attaches its own upgrade.
|
||||
* 3. HTTP 'upgrade' event (single entry point):
|
||||
* - Wrong path → socket.destroy()
|
||||
* - Origin not allowed → 401 + destroy()
|
||||
* - Passes → wss.handleUpgrade → wss.emit('connection')
|
||||
* 4. WS 'connection': wait for first 'attach' frame → handleAttach → send 'attached'
|
||||
* catch (spawn failure, M4) → send exit(-1) + close (only this connection).
|
||||
* 5. WS 'message': parse → ok:false discard+log; route input/resize.
|
||||
* 6. WS 'close' → manager.detachWs (never kills PTY, invariant #2).
|
||||
* 7. SIGINT/SIGTERM → manager.shutdown() + http server close.
|
||||
* Responsibilities (ARCHITECTURE §3.6): static hosting; WS upgrade gate (wrong
|
||||
* path → destroy; bad Origin → 401, L3); WS connection (first frame must be
|
||||
* 'attach', M4 spawn-failure → exit(-1)+close); message routing; close → detach
|
||||
* (never kills PTY, invariant #2); SIGINT/SIGTERM → shutdown. M5: every ws.send
|
||||
* guarded by readyState === WS_OPEN. L5: maxPayload cap. wsPath from config (#8).
|
||||
*
|
||||
* M5: every ws.send guarded by readyState === WS_OPEN.
|
||||
* L3: noServer:true — no double upgrade handling.
|
||||
* L5: maxPayload: cfg.maxPayloadBytes in WebSocketServer options.
|
||||
* No hardcoded strings: wsPath comes from config (invariant 8).
|
||||
* v0.7 (T-server-wire) adds out-of-band side-channel routes — the terminal
|
||||
* stream stays a byte-shuttle. A1 push (/push/*, /hook/decision + C1 hold gate),
|
||||
* A4 timeline (/…/events), A5 reaper sweepStuck, B1 diff (/projects/diff), B2
|
||||
* statusLine (/hook/status), B3 worktree (/projects/worktree), B4 approve mode,
|
||||
* GET /config/ui. State-changing routes carry requireAllowedOrigin (CSRF) and
|
||||
* per-IP rate limits; loopback-only ingest keeps isLoopback.
|
||||
*
|
||||
* Style: immutable data, explicit error handling, no silent swallowing.
|
||||
*/
|
||||
@@ -27,6 +22,8 @@ import net from 'node:net'
|
||||
import { URL } from 'node:url'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { stat } from 'node:fs/promises'
|
||||
|
||||
import express from 'express'
|
||||
import type { Response } from 'express'
|
||||
@@ -41,9 +38,20 @@ import { parseHookEvent } from './http/hook.js'
|
||||
import { listSessions } from './http/history.js'
|
||||
import { buildProjects, buildProjectDetail } from './http/projects.js'
|
||||
import { openInEditor } from './http/editor.js'
|
||||
import { getDiff } from './http/diff.js'
|
||||
import { parseStatusLine } from './http/statusline.js'
|
||||
import { createWorktree } from './http/worktrees.js'
|
||||
import { createSessionManager } from './session/manager.js'
|
||||
import { detachWs, writeInput, setClientDims } from './session/session.js'
|
||||
import type { Config } from './types.js'
|
||||
import { loadSubscriptionStore } from './push/subscription-store.js'
|
||||
import { createPushService } from './push/push-service.js'
|
||||
import type {
|
||||
Config,
|
||||
PermissionGate,
|
||||
PermissionMode,
|
||||
PushSubscriptionRecord,
|
||||
UiConfig,
|
||||
} from './types.js'
|
||||
import { WS_OPEN } from './types.js'
|
||||
|
||||
// ── defaults ──────────────────────────────────────────────────────────────────
|
||||
@@ -57,9 +65,78 @@ const RATE_WINDOW_MS = 1000
|
||||
// Max chars of a user-controlled string written to a log line (M2 log injection).
|
||||
const LOG_FIELD_MAX = 200
|
||||
|
||||
/** The decision JSON a PermissionRequest command hook writes to stdout. */
|
||||
function permDecision(behavior: 'allow' | 'deny'): unknown {
|
||||
return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior } } }
|
||||
// Per-IP sliding-window rate limits for the new state-changing routes (SEC-H9,
|
||||
// review #9). Fixed security policy — not user config.
|
||||
const RATE_LIMIT_WINDOW_MS = 60_000
|
||||
const DECISION_RATE_MAX = 10 // POST /hook/decision ≤ 10/min/IP
|
||||
const SUBSCRIBE_RATE_MAX = 5 // POST/DELETE /push/subscribe ≤ 5/min/IP
|
||||
|
||||
/** The four permission modes the WS approve relay accepts (B4); else undefined. */
|
||||
const PERMISSION_MODES: ReadonlySet<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.
|
||||
*/
|
||||
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`
|
||||
// is parked here until the client approves/rejects (or it times out).
|
||||
const pendingApprovals = new Map<string, { res: Response; timer: ReturnType<typeof setTimeout> }>()
|
||||
// Per-IP rate limiters for the new state-changing routes (SEC-H9).
|
||||
const decisionLimiter = createRateLimiter(DECISION_RATE_MAX, RATE_LIMIT_WINDOW_MS)
|
||||
const subscribeLimiter = createRateLimiter(SUBSCRIBE_RATE_MAX, RATE_LIMIT_WINDOW_MS)
|
||||
|
||||
// H3/A1: held PermissionRequest responses, keyed by sessionId. `res` is parked
|
||||
// until approve/reject or timeout. `token`+`expiresAt` = the per-decision
|
||||
// capability a remote device presents at /hook/decision (SEC-C1/M1); `gate`
|
||||
// (B4) lets a late-joining client re-render the right affordance (M3).
|
||||
interface PendingApproval {
|
||||
res: Response
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
token: string
|
||||
expiresAt: number
|
||||
gate: PermissionGate
|
||||
}
|
||||
const pendingApprovals = new Map<string, PendingApproval>()
|
||||
|
||||
function resolvePending(sessionId: string, decision: unknown): void {
|
||||
const p = pendingApprovals.get(sessionId)
|
||||
@@ -263,16 +357,25 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
res.status(403).end()
|
||||
return
|
||||
}
|
||||
const body = (req.body ?? {}) as Record<string, unknown>
|
||||
const ev = parseHookEvent(req.header('x-webterm-session'), req.body)
|
||||
if (ev === null) {
|
||||
res.status(400).end()
|
||||
return
|
||||
}
|
||||
manager.handleHookEvent(ev.sessionId, ev.status, ev.detail)
|
||||
// A4: pass event class + tool name so the manager appends a sanitized
|
||||
// timeline entry. Not a held gate → pending/gate stay undefined.
|
||||
const tool = typeof body['tool_name'] === 'string' ? body['tool_name'] : undefined
|
||||
manager.handleHookEvent(ev.sessionId, ev.status, ev.detail, undefined, undefined, ev.eventClass, tool)
|
||||
// A1: DONE push on terminal-completing events (best-effort; honors NOTIFY_DONE).
|
||||
if (ev.eventClass === 'Stop' || ev.eventClass === 'SessionEnd') {
|
||||
const session = manager.get(ev.sessionId)
|
||||
if (session !== undefined) void pushService.notify(session, 'done')
|
||||
}
|
||||
res.status(204).end()
|
||||
})
|
||||
|
||||
// H3: PermissionRequest hook — held until the client approves/rejects. The
|
||||
// H3/A1: PermissionRequest hook — held until the client approves/rejects. The
|
||||
// hook's curl writes our response (the decision JSON) to stdout for Claude.
|
||||
app.post('/hook/permission', express.json({ limit: '64kb' }), (req, res) => {
|
||||
if (!isLoopback(req.socket.remoteAddress ?? '')) {
|
||||
@@ -284,22 +387,205 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
const tool = typeof body['tool_name'] === 'string' ? body['tool_name'] : undefined
|
||||
const session = typeof sessionId === 'string' ? manager.get(sessionId) : undefined
|
||||
|
||||
// Nobody watching this tab (no session / no clients) → let Claude prompt itself.
|
||||
if (sessionId === undefined || session === undefined || session.clients.size === 0) {
|
||||
if (sessionId === undefined || session === undefined) {
|
||||
res.json({}) // no such session → let Claude prompt itself
|
||||
return
|
||||
}
|
||||
|
||||
// C1 hold gate (SEC-C2): hold if a client is watching OR push is enabled with
|
||||
// ≥1 subscription (so "walk away with zero tabs" still triggers lock-screen
|
||||
// approval); otherwise fall through to Claude's own prompt.
|
||||
const hasWatcher = session.clients.size > 0
|
||||
const hasPushTarget = pushService.isEnabled() && subStore.list().length > 0
|
||||
if (!hasWatcher && !hasPushTarget) {
|
||||
res.json({})
|
||||
return
|
||||
}
|
||||
|
||||
// B4: an ExitPlanMode request is a 'plan' gate (three-way), else a 'tool' gate.
|
||||
const gate: PermissionGate = tool === 'ExitPlanMode' ? 'plan' : 'tool'
|
||||
|
||||
resolvePending(sessionId, {}) // clear any stale hold for this session
|
||||
const token = randomUUID()
|
||||
const expiresAt = Date.now() + cfg.decisionTokenTtlMs
|
||||
const timer = setTimeout(() => {
|
||||
pendingApprovals.delete(sessionId)
|
||||
res.json({}) // timeout → fall back to Claude's interactive prompt
|
||||
manager.handleHookEvent(sessionId, 'idle')
|
||||
}, cfg.permTimeoutMs)
|
||||
pendingApprovals.set(sessionId, { res, timer })
|
||||
pendingApprovals.set(sessionId, { res, timer, token, expiresAt, gate })
|
||||
|
||||
// Show the approve/reject affordance on the client.
|
||||
manager.handleHookEvent(sessionId, 'waiting', tool, true)
|
||||
// A1: push the lock-screen approval (carrying the capability token) to every
|
||||
// subscribed device. Best-effort — failures are logged inside push-service.
|
||||
void pushService.notify(session, 'needs-input', token)
|
||||
|
||||
// Show the approve/reject affordance on every attached client.
|
||||
manager.handleHookEvent(sessionId, 'waiting', tool, true, gate)
|
||||
})
|
||||
|
||||
// ── A1 push subscription + lock-screen decision routes ────────────────────
|
||||
|
||||
// Public VAPID key for SW subscription. 503 when push is disabled (graceful).
|
||||
app.get('/push/vapid-key', (_req, res) => {
|
||||
if (!pushService.isEnabled() || cfg.vapidPublicKey === undefined) {
|
||||
res.status(503).end()
|
||||
return
|
||||
}
|
||||
res.json({ publicKey: cfg.vapidPublicKey })
|
||||
})
|
||||
|
||||
// Store a browser PushSubscription (state-changing → Origin guard + rate limit).
|
||||
app.post('/push/subscribe', express.json({ limit: '8kb' }), async (req, res) => {
|
||||
if (!requireAllowedOrigin(req, res)) return
|
||||
if (!subscribeLimiter(req.socket.remoteAddress ?? '', Date.now())) {
|
||||
res.status(429).end()
|
||||
return
|
||||
}
|
||||
const record = toSubscriptionRecord((req.body ?? {}) as Record<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 ───────────────────────────────────────────────────────────
|
||||
@@ -313,9 +599,11 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
maxPayload: cfg.maxPayloadBytes, // L5: cap single WS frame size
|
||||
})
|
||||
|
||||
// ── Idle reaper ───────────────────────────────────────────────────────────
|
||||
// ── Idle reaper (+ A5 stuck sweep, H4 — no new timer) ─────────────────────
|
||||
const reapTimer = setInterval(() => {
|
||||
manager.reapIdle(Date.now())
|
||||
const now = Date.now()
|
||||
manager.reapIdle(now)
|
||||
manager.sweepStuck(now)
|
||||
}, cfg.reapIntervalMs)
|
||||
// Don't let this timer prevent the process from exiting.
|
||||
reapTimer.unref()
|
||||
@@ -421,6 +709,17 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
|
||||
// Confirm the attach to the client.
|
||||
safeSend(ws, serialize({ type: 'attached', sessionId: session.meta.id }))
|
||||
|
||||
// M3 / AC-A1.3: a late-joining device immediately sees a still-held
|
||||
// approval (the manager re-sent telemetry + status; the pending/gate
|
||||
// portion lives here because the server owns pendingApprovals).
|
||||
const heldApproval = pendingApprovals.get(boundSessionId)
|
||||
if (heldApproval !== undefined) {
|
||||
safeSend(
|
||||
ws,
|
||||
serialize({ type: 'status', status: 'waiting', pending: true, gate: heldApproval.gate }),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -437,8 +736,12 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
// Latest-writer-wins: this device's dims drive the shared PTY size.
|
||||
setClientDims(session, ws, msg.cols, msg.rows)
|
||||
} else if (msg.type === 'approve') {
|
||||
// H3: resolve the held PermissionRequest with allow.
|
||||
resolvePending(boundSessionId, permDecision('allow'))
|
||||
// H3/B4: resolve the held PermissionRequest with allow, carrying the
|
||||
// optional permission mode. SEC-M5: downgrade the high-risk 'auto' mode
|
||||
// to 'default' unless ALLOW_AUTO_MODE is set (even if the FE hid it).
|
||||
let mode = parseApproveMode(text)
|
||||
if (mode === 'auto' && !cfg.allowAutoMode) mode = 'default'
|
||||
resolvePending(boundSessionId, permDecision('allow', mode))
|
||||
manager.handleHookEvent(boundSessionId, 'working', undefined, false)
|
||||
} else if (msg.type === 'reject') {
|
||||
resolvePending(boundSessionId, permDecision('deny'))
|
||||
|
||||
@@ -30,13 +30,18 @@ import type {
|
||||
Config,
|
||||
Dims,
|
||||
LiveSessionInfo,
|
||||
NotifyService,
|
||||
PermissionGate,
|
||||
ServerMessage,
|
||||
Session,
|
||||
SessionManager,
|
||||
StatusTelemetry,
|
||||
WebSocketLike,
|
||||
} from '../types.js';
|
||||
import { WS_OPEN } from '../types.js';
|
||||
import { serialize } from '../protocol.js';
|
||||
import { createSession, attachWs, broadcast, kill } from './session.js';
|
||||
import { appendEvent, makeTimelineEvent } from './timeline.js';
|
||||
import { hasSession, tmuxName } from './tmux.js';
|
||||
|
||||
/**
|
||||
@@ -57,8 +62,16 @@ function sendIfOpen(ws: WebSocketLike | null, msg: Parameters<typeof serialize>[
|
||||
* (add, delete) rather than in-place modification of the table structure.
|
||||
* Individual Session objects do carry mutable fields (attachedWs, detachedAt,
|
||||
* lastOutputAt, exitedAt, exitCode) by design — those are runtime handles.
|
||||
*
|
||||
* `notifyService` is injected (DI, M5) so the manager never imports push-service
|
||||
* directly (avoids a manager↔push-service cycle). sweepStuck uses it to push the
|
||||
* 'stuck' signal; when it is undefined (VAPID unset / tests) stuck detection still
|
||||
* updates status + broadcasts, it just doesn't send a push.
|
||||
*/
|
||||
export function createSessionManager(cfg: Config): SessionManager {
|
||||
export function createSessionManager(
|
||||
cfg: Config,
|
||||
notifyService?: NotifyService,
|
||||
): SessionManager {
|
||||
let sessions: Map<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) ───────────
|
||||
if (existing !== undefined && existing.exitedAt === null) {
|
||||
attachWs(existing, ws);
|
||||
// M3 / AC-B2.3: a late-joining device immediately gets the current
|
||||
// telemetry (if any) and status so it doesn't wait for the next
|
||||
// hook/statusLine report to refresh. The `pending`/`gate` portion of the
|
||||
// status is re-sent by the server layer (it owns pendingApprovals).
|
||||
if (existing.telemetry !== null) {
|
||||
sendIfOpen(ws, { type: 'telemetry', telemetry: existing.telemetry });
|
||||
}
|
||||
sendIfOpen(ws, { type: 'status', status: existing.claudeStatus });
|
||||
return existing;
|
||||
}
|
||||
|
||||
@@ -168,6 +189,7 @@ export function createSessionManager(cfg: Config): SessionManager {
|
||||
cwd: s.cwd,
|
||||
cols: s.pty.cols,
|
||||
rows: s.pty.rows,
|
||||
telemetry: s.telemetry, // B2: latest telemetry for the thumbnail wall
|
||||
}))
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
@@ -190,27 +212,77 @@ export function createSessionManager(cfg: Config): SessionManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* H2: a Claude Code hook reported activity for `sessionId`. Record it and push
|
||||
* a `status` frame to the attached ws (no-op if the session is gone/detached).
|
||||
* H2/H3: a Claude Code hook reported activity for `sessionId`. Update the
|
||||
* status, append a timeline event (A4) when `eventClass` is supplied and the
|
||||
* timeline is enabled, and broadcast a `status` frame — carrying `gate` (B4)
|
||||
* when the held approval is a 'tool'/'plan' gate. No-op for an unknown session.
|
||||
*/
|
||||
function handleHookEvent(
|
||||
sessionId: string,
|
||||
status: ClaudeStatus,
|
||||
detail?: string,
|
||||
pending?: boolean,
|
||||
gate?: PermissionGate,
|
||||
eventClass?: string,
|
||||
toolName?: string,
|
||||
): void {
|
||||
const session = sessions.get(sessionId);
|
||||
if (session === undefined) return;
|
||||
session.claudeStatus = status;
|
||||
const msg: { type: 'status'; status: ClaudeStatus; detail?: string; pending?: boolean } = {
|
||||
type: 'status',
|
||||
status,
|
||||
};
|
||||
|
||||
// A4: derive a bounded, sanitized timeline event from the raw hook name.
|
||||
// makeTimelineEvent whitelist-gates the event class (unknown → null = drop).
|
||||
if (cfg.timelineEnabled && eventClass !== undefined) {
|
||||
const ev = makeTimelineEvent(eventClass, toolName, Date.now());
|
||||
if (ev !== null) {
|
||||
session.timeline = appendEvent(session.timeline, ev, cfg.timelineMax);
|
||||
}
|
||||
}
|
||||
|
||||
const msg: Extract<ServerMessage, { type: 'status' }> = { type: 'status', status };
|
||||
if (detail !== undefined) msg.detail = detail;
|
||||
if (pending) msg.pending = true;
|
||||
if (gate !== undefined) msg.gate = gate;
|
||||
broadcast(session, msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* B2: store the latest statusLine telemetry for a session and broadcast a
|
||||
* `telemetry` frame to every attached client. No-op for an unknown session.
|
||||
*/
|
||||
function handleStatusLine(id: string, telemetry: StatusTelemetry): void {
|
||||
const session = sessions.get(id);
|
||||
if (session === undefined) return;
|
||||
session.telemetry = telemetry;
|
||||
broadcast(session, { type: 'telemetry', telemetry });
|
||||
}
|
||||
|
||||
/**
|
||||
* A5 (H4): on the existing reaper tick (no new timer), mark live, non-idle
|
||||
* sessions whose output has been silent past STUCK_TTL as 'stuck'. Covers BOTH
|
||||
* attached and detached living sessions. Sets the status, broadcasts it (§3.4
|
||||
* review #5 — not just a notify), and pushes a single 'stuck' alert per silent
|
||||
* round (re-armed by the next pty output in session.ts). Disabled when
|
||||
* stuckAlert is off or stuckTtlMs ≤ 0.
|
||||
*/
|
||||
function sweepStuck(now: number): void {
|
||||
if (!cfg.stuckAlert || cfg.stuckTtlMs <= 0) return;
|
||||
|
||||
for (const session of sessions.values()) {
|
||||
if (session.exitedAt !== null) continue; // already exited
|
||||
if (session.claudeStatus === 'idle') continue; // legitimately done
|
||||
if (session.stuckNotified) continue; // already alerted this round
|
||||
if (now - session.lastOutputAt <= cfg.stuckTtlMs) continue; // still fresh
|
||||
|
||||
session.claudeStatus = 'stuck';
|
||||
session.stuckNotified = true;
|
||||
broadcast(session, { type: 'status', status: 'stuck' });
|
||||
void notifyService?.notify(session, 'stuck').catch((err: unknown) => {
|
||||
console.error('[manager] stuck notification failed', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reclaim detached sessions whose liveness window has expired (M3).
|
||||
*
|
||||
@@ -259,5 +331,15 @@ export function createSessionManager(cfg: Config): SessionManager {
|
||||
sessions = new Map();
|
||||
}
|
||||
|
||||
return { handleAttach, get, list, killById, handleHookEvent, reapIdle, shutdown };
|
||||
return {
|
||||
handleAttach,
|
||||
get,
|
||||
list,
|
||||
killById,
|
||||
handleHookEvent,
|
||||
handleStatusLine,
|
||||
sweepStuck,
|
||||
reapIdle,
|
||||
shutdown,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
Session,
|
||||
SessionMeta,
|
||||
ServerMessage,
|
||||
TimelineEvent,
|
||||
WebSocketLike,
|
||||
} from '../types.js';
|
||||
import { WS_OPEN } from '../types.js';
|
||||
@@ -95,6 +96,9 @@ export function createSession(
|
||||
...process.env,
|
||||
WEBTERM_SESSION: id,
|
||||
WEBTERM_HOOK_URL: `http://127.0.0.1:${cfg.port}/hook`,
|
||||
// B2: statusLine scripts POST telemetry here; WEBTERM_NTFY_* vars forwarded
|
||||
// via ...process.env spread above (H3 — token set in env, not here).
|
||||
WEBTERM_STATUSLINE_URL: `http://127.0.0.1:${cfg.port}/hook/status`,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -116,12 +120,25 @@ export function createSession(
|
||||
cwd: cwd ?? null,
|
||||
tmuxName: tName,
|
||||
pty,
|
||||
// v0.7 Walk-away Workbench fields (T-spawn-env):
|
||||
timeline: Object.freeze([] as TimelineEvent[]),
|
||||
stuckNotified: false, // A5: re-armed to false by each pty output
|
||||
telemetry: null, // B2: updated by manager.handleStatusLine
|
||||
};
|
||||
|
||||
// onData: persist to scrollback, refresh liveness, broadcast to all clients.
|
||||
pty.onData((chunk) => {
|
||||
session.buffer.append(chunk);
|
||||
session.lastOutputAt = Date.now();
|
||||
// A5 §3.4: re-arm the stuck flag on every output so each silent round can
|
||||
// alert at most once. If we were stuck, recover to 'working' and broadcast
|
||||
// so clients clear the stuck badge immediately — this is the ONLY reliable
|
||||
// place to do this (same as lastOutputAt; T-manager never sees raw output).
|
||||
session.stuckNotified = false;
|
||||
if (session.claudeStatus === 'stuck') {
|
||||
session.claudeStatus = 'working';
|
||||
broadcast(session, { type: 'status', status: 'working' });
|
||||
}
|
||||
broadcast(session, { type: 'output', data: chunk });
|
||||
});
|
||||
|
||||
|
||||
143
src/session/timeline.ts
Normal file
143
src/session/timeline.ts
Normal 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 (0x00–0x1f) from `s`, then truncate to `max`
|
||||
* characters. Used for tool names and any other hook-supplied string fields.
|
||||
* (SEC-H6)
|
||||
*/
|
||||
export function sanitizeField(s: string, max = 200): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return s.replace(/[\x00-\x1f]/g, '').slice(0, max);
|
||||
}
|
||||
|
||||
// ── deriveClass ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Map a raw Claude Code hook event name to its semantic TimelineClass (§3.1).
|
||||
* Falls back to 'tool' for any event that doesn't appear in the map —
|
||||
* makeTimelineEvent already whitelist-gates, so this is just a safety net.
|
||||
*/
|
||||
export function deriveClass(eventName: string): TimelineClass {
|
||||
return CLASS_MAP[eventName] ?? 'tool';
|
||||
}
|
||||
|
||||
// ── deriveLabel ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Produce a human-language label for a hook event.
|
||||
* `toolName` is expected to already be sanitized when called from
|
||||
* makeTimelineEvent; callers must not pass raw hook data here directly.
|
||||
*/
|
||||
export function deriveLabel(eventName: string, toolName?: string): string {
|
||||
switch (eventName) {
|
||||
case 'PreToolUse':
|
||||
return toolName ? `using ${toolName}` : 'using tool';
|
||||
|
||||
case 'PostToolUse':
|
||||
if (toolName && EDIT_TOOLS.has(toolName)) {
|
||||
return `edited with ${toolName}`;
|
||||
}
|
||||
return toolName ? `ran ${toolName}` : 'ran tool';
|
||||
|
||||
case 'PermissionRequest':
|
||||
case 'Notification':
|
||||
return 'waiting for approval';
|
||||
|
||||
case 'Stop':
|
||||
case 'SessionEnd':
|
||||
return 'done';
|
||||
|
||||
case 'UserPromptSubmit':
|
||||
return 'user input';
|
||||
|
||||
default:
|
||||
return 'activity';
|
||||
}
|
||||
}
|
||||
|
||||
// ── makeTimelineEvent ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a TimelineEvent from raw hook data, or return null if the event name
|
||||
* is not in the whitelist (unknown hooks are silently dropped).
|
||||
*
|
||||
* Sanitizes `toolName` via sanitizeField before embedding it in the event or
|
||||
* passing it to deriveLabel (SEC-H6).
|
||||
*/
|
||||
export function makeTimelineEvent(
|
||||
eventName: string,
|
||||
toolName: string | undefined,
|
||||
at: number,
|
||||
): TimelineEvent | null {
|
||||
if (!WHITELISTED_EVENTS.has(eventName)) return null;
|
||||
|
||||
const sanitizedToolName =
|
||||
toolName !== undefined ? sanitizeField(toolName) : undefined;
|
||||
|
||||
return {
|
||||
at,
|
||||
class: deriveClass(eventName),
|
||||
toolName: sanitizedToolName,
|
||||
label: deriveLabel(eventName, sanitizedToolName),
|
||||
};
|
||||
}
|
||||
|
||||
// ── appendEvent ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return a NEW array with `ev` appended to `events`, evicting the oldest
|
||||
* entries if the length would exceed `maxLen`. Never mutates `events` (SEC-M8).
|
||||
*/
|
||||
export function appendEvent(
|
||||
events: readonly TimelineEvent[],
|
||||
ev: TimelineEvent,
|
||||
maxLen: number,
|
||||
): readonly TimelineEvent[] {
|
||||
const next = [...events, ev];
|
||||
if (next.length > maxLen) {
|
||||
return next.slice(next.length - maxLen);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
231
src/types.ts
231
src/types.ts
@@ -40,6 +40,35 @@ export interface Config {
|
||||
readonly projectScanTtlMs: number; // PROJECT_SCAN_TTL, default 10000 (discovery cache)
|
||||
readonly projectDirtyCheck: boolean; // PROJECT_DIRTY_CHECK, default true (git status per repo)
|
||||
readonly editorCmd: string; // EDITOR_CMD, default 'code' — POST /open-in-editor launches `<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.*`. */
|
||||
@@ -51,26 +80,43 @@ export type EnvLike = Readonly<Record<string, string | undefined>>;
|
||||
/* ─────────────────────── protocol (§3.2) ─────────────────────── */
|
||||
|
||||
/** client → server. approve/reject (H3) resolve a held PermissionRequest.
|
||||
* attach.cwd (M6) = spawn a new session in this directory ("new tab here"). */
|
||||
* attach.cwd (M6) = spawn a new session in this directory ("new tab here").
|
||||
* approve.mode (B4) = permission mode to write back when resolving a `plan`
|
||||
* gate (e.g. approve+auto → 'acceptEdits'); omitted for ordinary tool gates. */
|
||||
export type ClientMessage =
|
||||
| { type: 'attach'; sessionId: string | null; cwd?: string }
|
||||
| { type: 'input'; data: string }
|
||||
| { type: 'resize'; cols: number; rows: number }
|
||||
| { type: 'approve' }
|
||||
| { type: 'approve'; mode?: PermissionMode }
|
||||
| { type: 'reject' };
|
||||
|
||||
/** Claude Code activity, derived from hooks (H2). 'unknown' = no hook signal yet. */
|
||||
export type ClaudeStatus = 'working' | 'waiting' | 'idle' | 'unknown';
|
||||
/** Claude Code activity, derived from hooks (H2). 'unknown' = no hook signal yet.
|
||||
* 'stuck' (A5) = output silent past STUCK_TTL while not idle/exited; set by
|
||||
* manager.sweepStuck and re-armed to 'working' on the next pty output. */
|
||||
export type ClaudeStatus = 'working' | 'waiting' | 'idle' | 'unknown' | 'stuck';
|
||||
|
||||
/** Which kind of held approval a `status` carries (B4). 'plan' = an ExitPlanMode
|
||||
* gate (three-way approve/auto/keep-planning); 'tool' = an ordinary tool gate. */
|
||||
export type PermissionGate = 'tool' | 'plan';
|
||||
|
||||
/** server → client. exit.code = shell code; -1 when spawn never succeeded (M4).
|
||||
* exit.reason optional normally, REQUIRED on spawn failure / abnormal exit.
|
||||
* status (H2/H3) = Claude Code activity; `pending` true when a tool approval
|
||||
* is held server-side and the client can approve/reject it. */
|
||||
* is held server-side and the client can approve/reject it; `gate` (B4) tells
|
||||
* the client whether the held approval is a 'tool' or 'plan' gate.
|
||||
* telemetry (B2) = latest statusLine telemetry broadcast for this session. */
|
||||
export type ServerMessage =
|
||||
| { type: 'attached'; sessionId: string }
|
||||
| { type: 'output'; data: string }
|
||||
| { type: 'exit'; code: number; reason?: string }
|
||||
| { type: 'status'; status: ClaudeStatus; detail?: string; pending?: boolean };
|
||||
| {
|
||||
type: 'status';
|
||||
status: ClaudeStatus;
|
||||
detail?: string;
|
||||
pending?: boolean;
|
||||
gate?: PermissionGate;
|
||||
}
|
||||
| { type: 'telemetry'; telemetry: StatusTelemetry };
|
||||
|
||||
/** parseClientMessage result — never throws; errors flow here (§5.3). */
|
||||
export type ParseResult =
|
||||
@@ -172,6 +218,15 @@ export interface Session {
|
||||
cwd: string | null;
|
||||
/** tmux session name (H1) when running under tmux, else null. */
|
||||
readonly tmuxName: string | null;
|
||||
/** Bounded, timestamped activity ring (A4); newest appended, oldest evicted at
|
||||
* cfg.timelineMax. Mutable runtime handle on the immutable meta (like buffer).
|
||||
* Replaced wholesale via appendEvent (never mutated in place). */
|
||||
timeline: readonly TimelineEvent[];
|
||||
/** A5: true once a stuck alert fired this round; re-armed (→false) by the next
|
||||
* pty.onData so each silent round alerts at most once. */
|
||||
stuckNotified: boolean;
|
||||
/** B2: latest statusLine telemetry for this session; null until first report. */
|
||||
telemetry: StatusTelemetry | null;
|
||||
readonly pty: IPty;
|
||||
}
|
||||
|
||||
@@ -196,6 +251,7 @@ export interface LiveSessionInfo {
|
||||
cwd: string | null;
|
||||
cols: number; // current PTY size (for the manage page)
|
||||
rows: number;
|
||||
telemetry?: StatusTelemetry | null; // B2: latest telemetry for the thumbnail wall
|
||||
}
|
||||
|
||||
/* ───────────────── project manager (v0.6, §4.3 FEATURE doc) ──────────────── */
|
||||
@@ -262,20 +318,179 @@ export interface SessionManager {
|
||||
/** Kill a session by id (manage page): close its clients, kill the PTY, drop it.
|
||||
* Returns true if a session was found and killed. */
|
||||
killById(id: string): boolean;
|
||||
/** Set a session's Claude status (from a hook) and push it to the attached ws (H2/H3). */
|
||||
/** Set a session's Claude status (from a hook), append a timeline event (A4)
|
||||
* when eventClass is supplied + timeline is enabled, and broadcast to all
|
||||
* attached clients (H2/H3). gate (B4) marks a 'tool'/'plan' approval gate. */
|
||||
handleHookEvent(
|
||||
sessionId: string,
|
||||
status: ClaudeStatus,
|
||||
detail?: string,
|
||||
pending?: boolean,
|
||||
gate?: PermissionGate,
|
||||
eventClass?: string,
|
||||
toolName?: string,
|
||||
): void;
|
||||
/** B2: store the latest statusLine telemetry for a session and broadcast a
|
||||
* `telemetry` message to all attached clients. */
|
||||
handleStatusLine(id: string, telemetry: StatusTelemetry): void;
|
||||
/** A5: on the existing reaper tick (no new timer, H4), mark live non-idle
|
||||
* sessions whose output has been silent past STUCK_TTL as 'stuck', broadcast,
|
||||
* and notify once per silent round (re-armed by the next pty output). */
|
||||
sweepStuck(now: number): void;
|
||||
/** reclaim when now - max(detachedAt, lastOutputAt) > idleTtlMs (M3). Returns count. */
|
||||
reapIdle(now: number): number;
|
||||
shutdown(): void;
|
||||
}
|
||||
|
||||
// impl anchor [src/session/manager.ts]:
|
||||
// export function createSessionManager(cfg: Config): SessionManager
|
||||
// export function createSessionManager(cfg: Config, notifyService?: NotifyService): SessionManager
|
||||
// notifyService is injected (DI, M5) so the manager never imports push-service
|
||||
// directly (avoids a manager↔push-service cycle); sweepStuck uses it for 'stuck'.
|
||||
|
||||
/* ───────────── v0.7 Walk-away Workbench (FEATURE_WALKAWAY_WORKBENCH) ─────────────
|
||||
*
|
||||
* The five FE↔BE contract resolutions from PLAN_WALKAWAY_WORKBENCH §3, frozen
|
||||
* here so the frontend and backend agree on ONE spelling/shape. Field names for
|
||||
* StatusTelemetry (B2) and the value set for PermissionMode (B4) are derived
|
||||
* from the R0 statusLine / --permission-mode research findings.
|
||||
*/
|
||||
|
||||
/* ── B4 permission-mode relay (§3.5) ── */
|
||||
|
||||
/** --permission-mode values the relay exposes (R0-confirmed). 'auto' is its own
|
||||
* Claude Code mode (background safety checks), distinct from bypassPermissions
|
||||
* (the high-risk modes dontAsk/bypassPermissions are intentionally not surfaced).
|
||||
* Validated at the WS boundary; gated server-side by ALLOW_AUTO_MODE (SEC-M5). */
|
||||
export type PermissionMode = 'default' | 'acceptEdits' | 'plan' | 'auto';
|
||||
|
||||
/* ── A1 push notifications (§3.3, §A1) ── */
|
||||
|
||||
/** The three proactive signals pushed to the phone (§3.3 / §A1). */
|
||||
export type NotifyClass = 'needs-input' | 'done' | 'stuck';
|
||||
|
||||
/** Outbound push body — ONE shape: push-service sends it, sw-push.js reads `cls`
|
||||
* (§3.3 review #3). Minimal by design: no raw terminal output, no secrets.
|
||||
* `token` is the per-decision capability token (only on needs-input). */
|
||||
export interface PushPayload {
|
||||
sessionId: string;
|
||||
toolName?: string;
|
||||
detail?: string;
|
||||
token?: string;
|
||||
cls: NotifyClass;
|
||||
}
|
||||
|
||||
/** A stored browser PushSubscription (persisted to a 600-perm JSON, never via GET). */
|
||||
export interface PushSubscriptionRecord {
|
||||
endpoint: string;
|
||||
keys: { p256dh: string; auth: string };
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/** The notification provider, injected into the manager (DI, M5) so it never
|
||||
* imports push-service directly. isEnabled() is false when VAPID keys are unset
|
||||
* (graceful disable). notify() honors DND/NOTIFY_DONE internally. */
|
||||
export interface NotifyService {
|
||||
isEnabled(): boolean;
|
||||
notify(session: Session, cls: NotifyClass, token?: string): Promise<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) ──────────────────── */
|
||||
|
||||
|
||||
@@ -445,3 +445,288 @@ describe('loadConfig — project discovery (v0.6)', () => {
|
||||
expect(loadConfig({ EDITOR_CMD: ' ' }).editorCmd).toBe('code')
|
||||
})
|
||||
})
|
||||
|
||||
// ── v0.7 Walk-away Workbench — new env vars ───────────────────────────────────
|
||||
describe('loadConfig — v0.7 A1 push notifications', () => {
|
||||
beforeEach(() => {
|
||||
mockNetworkInterfaces.mockReturnValue({})
|
||||
mockHomedir.mockReturnValue('/home/testuser')
|
||||
})
|
||||
|
||||
it('VAPID_PUBLIC_KEY/PRIVATE_KEY: unset → both undefined', () => {
|
||||
const cfg = loadConfig({})
|
||||
expect(cfg.vapidPublicKey).toBeUndefined()
|
||||
expect(cfg.vapidPrivateKey).toBeUndefined()
|
||||
})
|
||||
|
||||
it('VAPID_PUBLIC_KEY/PRIVATE_KEY: reads from env', () => {
|
||||
const cfg = loadConfig({ VAPID_PUBLIC_KEY: 'pubkey123', VAPID_PRIVATE_KEY: 'privkey456' })
|
||||
expect(cfg.vapidPublicKey).toBe('pubkey123')
|
||||
expect(cfg.vapidPrivateKey).toBe('privkey456')
|
||||
})
|
||||
|
||||
it('VAPID_SUBJECT: unset → undefined, reads from env', () => {
|
||||
expect(loadConfig({}).vapidSubject).toBeUndefined()
|
||||
expect(loadConfig({ VAPID_SUBJECT: 'mailto:admin@example.com' }).vapidSubject).toBe('mailto:admin@example.com')
|
||||
})
|
||||
|
||||
it('PUSH_STORE_PATH: default is <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
483
test/diff.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -1,42 +1,54 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseHookEvent } from '../src/http/hook.js'
|
||||
import type { HookEventFull } from '../src/http/hook.js'
|
||||
|
||||
const SID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
|
||||
|
||||
describe('parseHookEvent', () => {
|
||||
it('maps tool events to "working"', () => {
|
||||
// ── Existing status / detail behaviour (must not regress) ───────────────────
|
||||
// We use expect.objectContaining() so the new fields (at/eventClass/gate) do
|
||||
// not break these assertion — they verify only the status-mapping contract.
|
||||
|
||||
describe('parseHookEvent — status mapping (regression guard)', () => {
|
||||
it('maps PreToolUse / PostToolUse / UserPromptSubmit to "working"', () => {
|
||||
for (const e of ['PreToolUse', 'PostToolUse', 'UserPromptSubmit']) {
|
||||
expect(parseHookEvent(SID, { hook_event_name: e })).toEqual({
|
||||
sessionId: SID,
|
||||
status: 'working',
|
||||
})
|
||||
expect(parseHookEvent(SID, { hook_event_name: e })).toEqual(
|
||||
expect.objectContaining({ sessionId: SID, status: 'working' }),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('maps PermissionRequest to "waiting" with the tool name as detail', () => {
|
||||
expect(parseHookEvent(SID, { hook_event_name: 'PermissionRequest', tool_name: 'Bash' })).toEqual(
|
||||
{ sessionId: SID, status: 'waiting', detail: 'Bash' },
|
||||
)
|
||||
it('maps PermissionRequest to "waiting" with tool_name as detail', () => {
|
||||
expect(
|
||||
parseHookEvent(SID, { hook_event_name: 'PermissionRequest', tool_name: 'Bash' }),
|
||||
).toEqual(expect.objectContaining({ sessionId: SID, status: 'waiting', detail: 'Bash' }))
|
||||
})
|
||||
|
||||
it('maps Notification permission_prompt to "waiting", idle_prompt to "idle"', () => {
|
||||
it('maps Notification(permission_prompt) → "waiting"; other Notification → "idle"', () => {
|
||||
expect(
|
||||
parseHookEvent(SID, { hook_event_name: 'Notification', notification_type: 'permission_prompt' }),
|
||||
).toEqual({ sessionId: SID, status: 'waiting' })
|
||||
parseHookEvent(SID, {
|
||||
hook_event_name: 'Notification',
|
||||
notification_type: 'permission_prompt',
|
||||
}),
|
||||
).toEqual(expect.objectContaining({ sessionId: SID, status: 'waiting' }))
|
||||
|
||||
expect(
|
||||
parseHookEvent(SID, { hook_event_name: 'Notification', notification_type: 'idle_prompt' }),
|
||||
).toEqual({ sessionId: SID, status: 'idle' })
|
||||
parseHookEvent(SID, {
|
||||
hook_event_name: 'Notification',
|
||||
notification_type: 'idle_prompt',
|
||||
}),
|
||||
).toEqual(expect.objectContaining({ sessionId: SID, status: 'idle' }))
|
||||
})
|
||||
|
||||
it('maps Stop / SessionEnd to "idle"', () => {
|
||||
expect(parseHookEvent(SID, { hook_event_name: 'Stop' })).toEqual({ sessionId: SID, status: 'idle' })
|
||||
expect(parseHookEvent(SID, { hook_event_name: 'SessionEnd' })).toEqual({
|
||||
sessionId: SID,
|
||||
status: 'idle',
|
||||
})
|
||||
expect(parseHookEvent(SID, { hook_event_name: 'Stop' })).toEqual(
|
||||
expect.objectContaining({ sessionId: SID, status: 'idle' }),
|
||||
)
|
||||
expect(parseHookEvent(SID, { hook_event_name: 'SessionEnd' })).toEqual(
|
||||
expect.objectContaining({ sessionId: SID, status: 'idle' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null for missing sessionId, bad body, or unknown event', () => {
|
||||
it('returns null for missing/empty sessionId, bad body, or unknown event', () => {
|
||||
expect(parseHookEvent(undefined, { hook_event_name: 'Stop' })).toBeNull()
|
||||
expect(parseHookEvent('', { hook_event_name: 'Stop' })).toBeNull()
|
||||
expect(parseHookEvent(SID, null)).toBeNull()
|
||||
@@ -45,3 +57,161 @@ describe('parseHookEvent', () => {
|
||||
expect(parseHookEvent(SID, {})).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ── HookEventFull — new A4 / B4 fields ──────────────────────────────────────
|
||||
|
||||
describe('HookEventFull — at field (A4)', () => {
|
||||
it('includes a numeric timestamp in [before, after] around the call', () => {
|
||||
const before = Date.now()
|
||||
const ev = parseHookEvent(SID, { hook_event_name: 'Stop' }) as HookEventFull
|
||||
const after = Date.now()
|
||||
|
||||
expect(ev).not.toBeNull()
|
||||
expect(typeof ev.at).toBe('number')
|
||||
expect(ev.at).toBeGreaterThanOrEqual(before)
|
||||
expect(ev.at).toBeLessThanOrEqual(after)
|
||||
})
|
||||
|
||||
it('is absent from null results', () => {
|
||||
const ev = parseHookEvent(SID, { hook_event_name: 'Unknown' })
|
||||
expect(ev).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('HookEventFull — eventClass field (A4)', () => {
|
||||
const EVENTS = [
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'UserPromptSubmit',
|
||||
'PermissionRequest',
|
||||
'Stop',
|
||||
'SessionEnd',
|
||||
'Notification',
|
||||
]
|
||||
|
||||
it('carries the raw hook_event_name for every whitelisted event', () => {
|
||||
for (const e of EVENTS) {
|
||||
const ev = parseHookEvent(SID, { hook_event_name: e }) as HookEventFull
|
||||
expect(ev).not.toBeNull()
|
||||
expect(ev.eventClass).toBe(e)
|
||||
}
|
||||
})
|
||||
|
||||
it('is not present when parseHookEvent returns null', () => {
|
||||
expect(parseHookEvent(SID, { hook_event_name: 'NotAHook' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('HookEventFull — toolInput passthrough (A4)', () => {
|
||||
it('includes toolInput when tool_input is present in the body', () => {
|
||||
const input = { path: '/src/foo.ts', content: 'hello world' }
|
||||
const ev = parseHookEvent(SID, {
|
||||
hook_event_name: 'PreToolUse',
|
||||
tool_input: input,
|
||||
}) as HookEventFull
|
||||
|
||||
expect(ev).not.toBeNull()
|
||||
expect(ev.toolInput).toBe(input) // same reference, no copy/parse
|
||||
})
|
||||
|
||||
it('passes toolInput through as-is (does not validate inner shape)', () => {
|
||||
const ev = parseHookEvent(SID, {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_input: 'just a string',
|
||||
}) as HookEventFull
|
||||
|
||||
expect(ev.toolInput).toBe('just a string')
|
||||
})
|
||||
|
||||
it('omits toolInput when tool_input is absent', () => {
|
||||
const ev = parseHookEvent(SID, { hook_event_name: 'PreToolUse' }) as HookEventFull
|
||||
expect(ev).not.toBeNull()
|
||||
expect('toolInput' in ev).toBe(false)
|
||||
})
|
||||
|
||||
it('includes toolInput even when its value is null', () => {
|
||||
const ev = parseHookEvent(SID, {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_input: null,
|
||||
}) as HookEventFull
|
||||
expect(ev).not.toBeNull()
|
||||
expect('toolInput' in ev).toBe(true)
|
||||
expect(ev.toolInput).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('HookEventFull — gate field (B4 plan-gate recognition)', () => {
|
||||
it('sets gate="plan" for PermissionRequest with ExitPlanMode tool', () => {
|
||||
const ev = parseHookEvent(SID, {
|
||||
hook_event_name: 'PermissionRequest',
|
||||
tool_name: 'ExitPlanMode',
|
||||
}) as HookEventFull
|
||||
|
||||
expect(ev).not.toBeNull()
|
||||
expect(ev.gate).toBe('plan')
|
||||
})
|
||||
|
||||
it('sets gate="tool" for PermissionRequest with any other tool name', () => {
|
||||
const ev = parseHookEvent(SID, {
|
||||
hook_event_name: 'PermissionRequest',
|
||||
tool_name: 'Bash',
|
||||
}) as HookEventFull
|
||||
|
||||
expect(ev).not.toBeNull()
|
||||
expect(ev.gate).toBe('tool')
|
||||
})
|
||||
|
||||
it('sets gate="tool" for PermissionRequest with no tool_name', () => {
|
||||
const ev = parseHookEvent(SID, { hook_event_name: 'PermissionRequest' }) as HookEventFull
|
||||
|
||||
expect(ev).not.toBeNull()
|
||||
expect(ev.gate).toBe('tool')
|
||||
})
|
||||
|
||||
it('omits gate for non-PermissionRequest events', () => {
|
||||
const nonPermEvents = [
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'UserPromptSubmit',
|
||||
'Stop',
|
||||
'SessionEnd',
|
||||
'Notification',
|
||||
]
|
||||
for (const e of nonPermEvents) {
|
||||
const ev = parseHookEvent(SID, { hook_event_name: e }) as HookEventFull
|
||||
expect(ev).not.toBeNull()
|
||||
expect('gate' in ev).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('HookEventFull — detail not set for Notification (no tool_name)', () => {
|
||||
it('Notification(permission_prompt) has status=waiting but no detail', () => {
|
||||
const ev = parseHookEvent(SID, {
|
||||
hook_event_name: 'Notification',
|
||||
notification_type: 'permission_prompt',
|
||||
}) as HookEventFull
|
||||
|
||||
expect(ev).not.toBeNull()
|
||||
expect(ev.status).toBe('waiting')
|
||||
expect('detail' in ev).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SEC-M7 — never throws on any body shape', () => {
|
||||
const malformed = [
|
||||
[],
|
||||
42,
|
||||
true,
|
||||
undefined,
|
||||
{ hook_event_name: null },
|
||||
{ hook_event_name: 123 },
|
||||
{ hook_event_name: 'PreToolUse', tool_input: { toString: null } },
|
||||
]
|
||||
|
||||
it('returns null without throwing for every malformed body', () => {
|
||||
for (const b of malformed) {
|
||||
expect(() => parseHookEvent(SID, b)).not.toThrow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
348
test/http/diff.test.ts
Normal file
348
test/http/diff.test.ts
Normal 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` [31mred'
|
||||
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)
|
||||
})
|
||||
})
|
||||
373
test/http/statusline.test.ts
Normal file
373
test/http/statusline.test.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
205
test/http/worktrees-create.test.ts
Normal file
205
test/http/worktrees-create.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
422
test/integration/push.test.ts
Normal file
422
test/integration/push.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
217
test/integration/timeline-events.test.ts
Normal file
217
test/integration/timeline-events.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
203
test/integration/worktree.test.ts
Normal file
203
test/integration/worktree.test.ts
Normal 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>')
|
||||
})
|
||||
})
|
||||
@@ -13,7 +13,14 @@
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
import type { Config, Dims, Session, WebSocketLike } from '../src/types.js';
|
||||
import type {
|
||||
Config,
|
||||
Dims,
|
||||
NotifyService,
|
||||
Session,
|
||||
StatusTelemetry,
|
||||
WebSocketLike,
|
||||
} from '../src/types.js';
|
||||
import { WS_OPEN } from '../src/types.js';
|
||||
import { createMockPty, type MockIPty } from './helpers/mock-pty.js';
|
||||
|
||||
@@ -53,10 +60,53 @@ const CFG: Config = {
|
||||
previewBytes: 24 * 1024,
|
||||
useTmux: false,
|
||||
allowedOrigins: [],
|
||||
// v0.6 project manager
|
||||
projectRoots: ['/home/tester'],
|
||||
projectScanDepth: 4,
|
||||
projectScanTtlMs: 10_000,
|
||||
projectDirtyCheck: true,
|
||||
editorCmd: 'code',
|
||||
// v0.7 Walk-away Workbench
|
||||
vapidPublicKey: undefined,
|
||||
vapidPrivateKey: undefined,
|
||||
vapidSubject: undefined,
|
||||
pushStorePath: '/home/tester/.web-terminal-push-subs.json',
|
||||
pushMaxSubs: 50,
|
||||
notifyDone: true,
|
||||
notifyDnd: false,
|
||||
decisionTokenTtlMs: 300_000,
|
||||
timelineMax: 200,
|
||||
timelineEnabled: true,
|
||||
stuckTtlMs: 10_000, // 10 s for easy arithmetic in tests
|
||||
stuckAlert: true,
|
||||
diffTimeoutMs: 2000,
|
||||
diffMaxBytes: 2 * 1024 * 1024,
|
||||
diffMaxFiles: 300,
|
||||
statuslineTtlMs: 30_000,
|
||||
worktreeEnabled: true,
|
||||
worktreeRoot: undefined,
|
||||
worktreeTimeoutMs: 10_000,
|
||||
defaultPermissionMode: 'default',
|
||||
allowAutoMode: false,
|
||||
};
|
||||
|
||||
const DIMS: Dims = { cols: 80, rows: 24 };
|
||||
|
||||
/** A telemetry sample for B2 tests. */
|
||||
function makeTelemetry(at = 1_000): StatusTelemetry {
|
||||
return { contextUsedPct: 42, costUsd: 1.23, model: 'sonnet', at };
|
||||
}
|
||||
|
||||
/** A mock NotifyService (DI) recording every notify(session, cls, token?) call. */
|
||||
function createMockNotify() {
|
||||
const notify = vi.fn(async (_session: Session, _cls: string, _token?: string) => {});
|
||||
const service: NotifyService = {
|
||||
isEnabled: () => true,
|
||||
notify,
|
||||
};
|
||||
return { service, notify };
|
||||
}
|
||||
|
||||
interface MockWs extends WebSocketLike {
|
||||
readyState: number;
|
||||
sent: string[];
|
||||
@@ -646,3 +696,356 @@ describe('onExit removes session from table when ws attached at exit time (L2)',
|
||||
expect(session.exitedAt).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── handleHookEvent — A4 timeline + B4 gate (T-manager) ───────────────────────
|
||||
describe('handleHookEvent — timeline append (A4)', () => {
|
||||
it('appends a derived timeline event when eventClass is supplied + timeline enabled', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
|
||||
mgr.handleHookEvent(s.meta.id, 'working', 'Bash', false, undefined, 'PreToolUse', 'Bash');
|
||||
|
||||
expect(s.timeline).toHaveLength(1);
|
||||
expect(s.timeline[0]).toMatchObject({ class: 'tool', toolName: 'Bash', label: 'using Bash' });
|
||||
expect(typeof s.timeline[0]?.at).toBe('number');
|
||||
});
|
||||
|
||||
it('replaces the timeline array immutably (never mutates the previous one)', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
const before = s.timeline;
|
||||
|
||||
mgr.handleHookEvent(s.meta.id, 'working', undefined, false, undefined, 'PreToolUse', 'Bash');
|
||||
|
||||
expect(s.timeline).not.toBe(before);
|
||||
expect(before).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does NOT append when timeline is disabled', () => {
|
||||
const mgr = createSessionManager({ ...CFG, timelineEnabled: false });
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
|
||||
mgr.handleHookEvent(s.meta.id, 'working', undefined, false, undefined, 'PreToolUse', 'Bash');
|
||||
|
||||
expect(s.timeline).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does NOT append when eventClass is omitted', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
|
||||
mgr.handleHookEvent(s.meta.id, 'waiting', 'Bash', true);
|
||||
|
||||
expect(s.timeline).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('drops non-whitelisted event classes (makeTimelineEvent → null)', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
|
||||
mgr.handleHookEvent(s.meta.id, 'working', undefined, false, undefined, 'BogusEvent', 'X');
|
||||
|
||||
expect(s.timeline).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('evicts oldest events past cfg.timelineMax', () => {
|
||||
const mgr = createSessionManager({ ...CFG, timelineMax: 3 });
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
mgr.handleHookEvent(s.meta.id, 'working', undefined, false, undefined, 'PreToolUse', `Tool${i}`);
|
||||
}
|
||||
|
||||
expect(s.timeline).toHaveLength(3);
|
||||
expect(s.timeline.map((e) => e.toolName)).toEqual(['Tool2', 'Tool3', 'Tool4']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleHookEvent — gate broadcast (B4)', () => {
|
||||
it('broadcasts the status frame with gate when supplied', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
ws.sent.length = 0;
|
||||
|
||||
mgr.handleHookEvent(s.meta.id, 'waiting', 'ExitPlanMode', true, 'plan', 'PermissionRequest', 'ExitPlanMode');
|
||||
|
||||
const status = parseSent(ws).find((m) => m.type === 'status');
|
||||
expect(status).toMatchObject({
|
||||
type: 'status',
|
||||
status: 'waiting',
|
||||
detail: 'ExitPlanMode',
|
||||
pending: true,
|
||||
gate: 'plan',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits gate when not supplied', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
ws.sent.length = 0;
|
||||
|
||||
mgr.handleHookEvent(s.meta.id, 'waiting', 'Bash', true);
|
||||
|
||||
const status = parseSent(ws).find((m) => m.type === 'status');
|
||||
expect(status).not.toHaveProperty('gate');
|
||||
});
|
||||
});
|
||||
|
||||
// ── handleStatusLine (B2 telemetry) ───────────────────────────────────────────
|
||||
describe('handleStatusLine', () => {
|
||||
it('stores telemetry on the session and broadcasts it to all clients', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
const a = createMockWs();
|
||||
const b = createMockWs();
|
||||
const s = mgr.handleAttach(a, null, DIMS, 1_000);
|
||||
mgr.handleAttach(b, s.meta.id, DIMS, 2_000);
|
||||
a.sent.length = 0;
|
||||
b.sent.length = 0;
|
||||
|
||||
const telemetry = makeTelemetry();
|
||||
mgr.handleStatusLine(s.meta.id, telemetry);
|
||||
|
||||
expect(s.telemetry).toBe(telemetry);
|
||||
for (const ws of [a, b]) {
|
||||
const msg = parseSent(ws).find((m) => m.type === 'telemetry');
|
||||
expect(msg).toMatchObject({ type: 'telemetry', telemetry: { costUsd: 1.23, model: 'sonnet' } });
|
||||
}
|
||||
});
|
||||
|
||||
it('is a no-op for an unknown session id', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
expect(() => mgr.handleStatusLine('00000000-0000-4000-8000-0000000000cc', makeTelemetry())).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── handleAttach Case 2 — late-join telemetry/status replay (M3 / AC-B2.3) ─────
|
||||
describe('handleAttach — late-join replay (M3)', () => {
|
||||
it('sends the current telemetry to a device joining a live session', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
const a = createMockWs();
|
||||
const s = mgr.handleAttach(a, null, DIMS, 1_000);
|
||||
mgr.handleStatusLine(s.meta.id, makeTelemetry());
|
||||
|
||||
const b = createMockWs();
|
||||
mgr.handleAttach(b, s.meta.id, DIMS, 2_000);
|
||||
|
||||
const msg = parseSent(b).find((m) => m.type === 'telemetry');
|
||||
expect(msg).toMatchObject({ type: 'telemetry', telemetry: { model: 'sonnet' } });
|
||||
});
|
||||
|
||||
it('sends the current status to a late-joining device', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
const a = createMockWs();
|
||||
const s = mgr.handleAttach(a, null, DIMS, 1_000);
|
||||
mgr.handleHookEvent(s.meta.id, 'working');
|
||||
|
||||
const b = createMockWs();
|
||||
mgr.handleAttach(b, s.meta.id, DIMS, 2_000);
|
||||
|
||||
const status = parseSent(b).find((m) => m.type === 'status');
|
||||
expect(status).toMatchObject({ type: 'status', status: 'working' });
|
||||
});
|
||||
|
||||
it('does NOT send a telemetry frame when the session has no telemetry yet', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
const a = createMockWs();
|
||||
const s = mgr.handleAttach(a, null, DIMS, 1_000);
|
||||
|
||||
const b = createMockWs();
|
||||
mgr.handleAttach(b, s.meta.id, DIMS, 2_000);
|
||||
|
||||
expect(parseSent(b).some((m) => m.type === 'telemetry')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── sweepStuck (A5) ───────────────────────────────────────────────────────────
|
||||
describe('sweepStuck (A5)', () => {
|
||||
it('marks a silent live session stuck, broadcasts, and notifies once', () => {
|
||||
const { service, notify } = createMockNotify();
|
||||
const mgr = createSessionManager(CFG, service);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000); // lastOutputAt = 1000
|
||||
ws.sent.length = 0;
|
||||
|
||||
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
|
||||
|
||||
expect(s.claudeStatus).toBe('stuck');
|
||||
expect(s.stuckNotified).toBe(true);
|
||||
const status = parseSent(ws).find((m) => m.type === 'status');
|
||||
expect(status).toMatchObject({ type: 'status', status: 'stuck' });
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
expect(notify).toHaveBeenCalledWith(s, 'stuck');
|
||||
});
|
||||
|
||||
it('covers ATTACHED sessions, not only detached ones (AC-A5.3)', () => {
|
||||
const { service, notify } = createMockNotify();
|
||||
const mgr = createSessionManager(CFG, service);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
// still attached: clients.size === 1, detachedAt === null
|
||||
|
||||
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
|
||||
|
||||
expect(s.detachedAt).toBeNull();
|
||||
expect(s.claudeStatus).toBe('stuck');
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('alerts at most once per silent round (AC-A5.1)', () => {
|
||||
const { service, notify } = createMockNotify();
|
||||
const mgr = createSessionManager(CFG, service);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
|
||||
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
|
||||
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 9_999);
|
||||
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
expect(s.claudeStatus).toBe('stuck');
|
||||
});
|
||||
|
||||
it('re-arms after output recovery so a later silence alerts again (AC-A5.2)', () => {
|
||||
const { service, notify } = createMockNotify();
|
||||
const mgr = createSessionManager(CFG, service);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
|
||||
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Output recovery (session.ts onData) re-arms the flag + status.
|
||||
(s.pty as MockIPty).emitData('back to work');
|
||||
expect(s.stuckNotified).toBe(false);
|
||||
expect(s.claudeStatus).toBe('working');
|
||||
s.lastOutputAt = 100_000; // pin to a known cursor
|
||||
|
||||
mgr.sweepStuck(100_000 + CFG.stuckTtlMs + 1);
|
||||
expect(notify).toHaveBeenCalledTimes(2);
|
||||
expect(s.claudeStatus).toBe('stuck');
|
||||
});
|
||||
|
||||
it('skips idle sessions', () => {
|
||||
const { service, notify } = createMockNotify();
|
||||
const mgr = createSessionManager(CFG, service);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
s.claudeStatus = 'idle';
|
||||
|
||||
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
|
||||
|
||||
expect(s.claudeStatus).toBe('idle');
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips already-exited sessions (kept in table after detach-then-exit)', () => {
|
||||
const { service, notify } = createMockNotify();
|
||||
const mgr = createSessionManager(CFG, service);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
s.clients.clear();
|
||||
s.detachedAt = 2_000;
|
||||
(s.pty as MockIPty).emitExit(0); // detached-then-exit → stays in table (L1)
|
||||
|
||||
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
|
||||
|
||||
expect(s.claudeStatus).not.toBe('stuck');
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not mark a session whose output is within the TTL window', () => {
|
||||
const { service, notify } = createMockNotify();
|
||||
const mgr = createSessionManager(CFG, service);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
|
||||
mgr.sweepStuck(1_000 + CFG.stuckTtlMs - 1);
|
||||
|
||||
expect(s.claudeStatus).not.toBe('stuck');
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is disabled when stuckAlert is false', () => {
|
||||
const { service, notify } = createMockNotify();
|
||||
const mgr = createSessionManager({ ...CFG, stuckAlert: false }, service);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
|
||||
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
|
||||
|
||||
expect(s.claudeStatus).not.toBe('stuck');
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is disabled when stuckTtlMs is 0', () => {
|
||||
const { service, notify } = createMockNotify();
|
||||
const mgr = createSessionManager({ ...CFG, stuckTtlMs: 0 }, service);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
|
||||
mgr.sweepStuck(99_999_999);
|
||||
|
||||
expect(s.claudeStatus).not.toBe('stuck');
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows a notifyService rejection (logs via console.error, never throws)', async () => {
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const notify = vi.fn(async () => {
|
||||
throw new Error('push transport down');
|
||||
});
|
||||
const service: NotifyService = { isEnabled: () => true, notify };
|
||||
const mgr = createSessionManager(CFG, service);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
|
||||
expect(() => mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1)).not.toThrow();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(s.claudeStatus).toBe('stuck');
|
||||
expect(errSpy).toHaveBeenCalled();
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('works without an injected notifyService (broadcasts, no throw)', () => {
|
||||
const mgr = createSessionManager(CFG); // no notifyService
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
ws.sent.length = 0;
|
||||
|
||||
expect(() => mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1)).not.toThrow();
|
||||
expect(s.claudeStatus).toBe('stuck');
|
||||
expect(parseSent(ws).some((m) => m.type === 'status' && m.status === 'stuck')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── list() carries telemetry (B2 thumbnail wall) ──────────────────────────────
|
||||
describe('list — telemetry field', () => {
|
||||
it('includes the latest telemetry for each session', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
const telemetry = makeTelemetry();
|
||||
mgr.handleStatusLine(s.meta.id, telemetry);
|
||||
|
||||
const entry = mgr.list().find((e) => e.id === s.meta.id);
|
||||
expect(entry?.telemetry).toBe(telemetry);
|
||||
});
|
||||
|
||||
it('reports null telemetry before the first statusLine report', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
const ws = createMockWs();
|
||||
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
|
||||
|
||||
const entry = mgr.list().find((e) => e.id === s.meta.id);
|
||||
expect(entry?.telemetry).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
508
test/push.test.ts
Normal file
508
test/push.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
326
test/push/push-service.test.ts
Normal file
326
test/push/push-service.test.ts
Normal 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
|
||||
170
test/push/subscription-store.test.ts
Normal file
170
test/push/subscription-store.test.ts
Normal 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
552
test/quick-reply.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -53,6 +53,12 @@ const CFG: Config = {
|
||||
previewBytes: 24 * 1024,
|
||||
useTmux: false,
|
||||
allowedOrigins: [],
|
||||
// v0.6 project manager fields
|
||||
projectRoots: ['/home/tester'],
|
||||
projectScanDepth: 4,
|
||||
projectScanTtlMs: 10_000,
|
||||
projectDirtyCheck: true,
|
||||
editorCmd: 'code',
|
||||
};
|
||||
|
||||
const DIMS: Dims = { cols: 80, rows: 24 };
|
||||
@@ -407,3 +413,129 @@ describe('kill', () => {
|
||||
expect((s.pty as MockIPty).killed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── spawn env (T-spawn-env / B2) ──────────────────────────────────────────────
|
||||
describe('spawn env', () => {
|
||||
it('injects WEBTERM_STATUSLINE_URL pointing to /hook/status (B2)', () => {
|
||||
newSession();
|
||||
const opts = spawnCalls[0]!.options as { env?: Record<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' });
|
||||
});
|
||||
});
|
||||
|
||||
349
test/session/timeline.test.ts
Normal file
349
test/session/timeline.test.ts
Normal 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 (0x00–0x1f)', () => {
|
||||
// NUL, LF, CR, ESC, tab should all be removed
|
||||
const input = '\x00Bash\x09script\x0a\x0d\x1b[1m';
|
||||
expect(sanitizeField(input)).toBe('Bashscript[1m');
|
||||
});
|
||||
|
||||
it('strips null bytes embedded in the string', () => {
|
||||
expect(sanitizeField('he\x00llo')).toBe('hello');
|
||||
});
|
||||
|
||||
it('truncates to the default max of 200 characters', () => {
|
||||
const long = 'a'.repeat(300);
|
||||
const result = sanitizeField(long);
|
||||
expect(result.length).toBe(200);
|
||||
});
|
||||
|
||||
it('truncates to a custom max', () => {
|
||||
const result = sanitizeField('abcdef', 3);
|
||||
expect(result).toBe('abc');
|
||||
});
|
||||
|
||||
it('handles an empty string', () => {
|
||||
expect(sanitizeField('')).toBe('');
|
||||
});
|
||||
|
||||
it('does not truncate strings exactly at the max', () => {
|
||||
const exact = 'x'.repeat(200);
|
||||
expect(sanitizeField(exact).length).toBe(200);
|
||||
});
|
||||
|
||||
it('strips control chars before truncating (strip-then-truncate)', () => {
|
||||
// 10 control chars + 5 regular chars; after strip only 5 remain → under max
|
||||
const withControls = '\x01'.repeat(10) + 'hello';
|
||||
expect(sanitizeField(withControls, 8)).toBe('hello');
|
||||
});
|
||||
});
|
||||
|
||||
// ── deriveClass ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('deriveClass', () => {
|
||||
it('maps PreToolUse → "tool"', () => {
|
||||
expect(deriveClass('PreToolUse')).toBe('tool');
|
||||
});
|
||||
|
||||
it('maps PostToolUse → "tool"', () => {
|
||||
expect(deriveClass('PostToolUse')).toBe('tool');
|
||||
});
|
||||
|
||||
it('maps PermissionRequest → "waiting"', () => {
|
||||
expect(deriveClass('PermissionRequest')).toBe('waiting');
|
||||
});
|
||||
|
||||
it('maps Notification → "waiting"', () => {
|
||||
expect(deriveClass('Notification')).toBe('waiting');
|
||||
});
|
||||
|
||||
it('maps Stop → "done"', () => {
|
||||
expect(deriveClass('Stop')).toBe('done');
|
||||
});
|
||||
|
||||
it('maps SessionEnd → "done"', () => {
|
||||
expect(deriveClass('SessionEnd')).toBe('done');
|
||||
});
|
||||
|
||||
it('maps UserPromptSubmit → "user"', () => {
|
||||
expect(deriveClass('UserPromptSubmit')).toBe('user');
|
||||
});
|
||||
|
||||
it('returns "tool" as a safe fallback for unknown event names', () => {
|
||||
expect(deriveClass('SomeUnknownEvent')).toBe('tool');
|
||||
});
|
||||
});
|
||||
|
||||
// ── deriveLabel ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('deriveLabel', () => {
|
||||
describe('PreToolUse', () => {
|
||||
it('returns a label that includes the tool name when provided', () => {
|
||||
const label = deriveLabel('PreToolUse', 'Bash');
|
||||
expect(label).toContain('Bash');
|
||||
});
|
||||
|
||||
it('returns a generic label when toolName is absent', () => {
|
||||
const label = deriveLabel('PreToolUse', undefined);
|
||||
expect(label.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PostToolUse', () => {
|
||||
it('returns a "ran" label for execution tools (Bash)', () => {
|
||||
const label = deriveLabel('PostToolUse', 'Bash');
|
||||
expect(label.toLowerCase()).toContain('bash');
|
||||
});
|
||||
|
||||
it('returns an "edit" flavour label for Edit tool', () => {
|
||||
const label = deriveLabel('PostToolUse', 'Edit');
|
||||
// Should be something like "edited file" or "ran Edit" — contract just says it's human
|
||||
expect(label.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns a generic label when toolName is absent', () => {
|
||||
const label = deriveLabel('PostToolUse', undefined);
|
||||
expect(label.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('waiting events', () => {
|
||||
it('returns an approval-flavoured label for PermissionRequest', () => {
|
||||
const label = deriveLabel('PermissionRequest', undefined);
|
||||
expect(label.length).toBeGreaterThan(0);
|
||||
expect(label.toLowerCase()).toMatch(/wait|approv|permiss/);
|
||||
});
|
||||
|
||||
it('returns an approval-flavoured label for Notification', () => {
|
||||
const label = deriveLabel('Notification', undefined);
|
||||
expect(label.toLowerCase()).toMatch(/wait|approv|permiss/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('done events', () => {
|
||||
it('returns a "done" label for Stop', () => {
|
||||
expect(deriveLabel('Stop', undefined)).toMatch(/done|stop|end/i);
|
||||
});
|
||||
|
||||
it('returns a "done" label for SessionEnd', () => {
|
||||
expect(deriveLabel('SessionEnd', undefined)).toMatch(/done|stop|end/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('user input', () => {
|
||||
it('returns a user-input label for UserPromptSubmit', () => {
|
||||
const label = deriveLabel('UserPromptSubmit', undefined);
|
||||
expect(label.toLowerCase()).toMatch(/user|input|prompt/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── makeTimelineEvent ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('makeTimelineEvent', () => {
|
||||
it('returns null for an unknown event name (whitelist guard)', () => {
|
||||
expect(makeTimelineEvent('UnknownHookType', undefined, 1000)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an empty string event name', () => {
|
||||
expect(makeTimelineEvent('', undefined, 1000)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns a TimelineEvent for PreToolUse', () => {
|
||||
const ev = makeTimelineEvent('PreToolUse', 'Bash', 1234);
|
||||
expect(ev).not.toBeNull();
|
||||
expect(ev!.at).toBe(1234);
|
||||
expect(ev!.class).toBe('tool');
|
||||
expect(ev!.toolName).toBe('Bash');
|
||||
expect(ev!.label.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns a TimelineEvent for PostToolUse', () => {
|
||||
const ev = makeTimelineEvent('PostToolUse', 'Bash', 9999);
|
||||
expect(ev).not.toBeNull();
|
||||
expect(ev!.class).toBe('tool');
|
||||
expect(ev!.toolName).toBe('Bash');
|
||||
});
|
||||
|
||||
it('returns a TimelineEvent for PermissionRequest', () => {
|
||||
const ev = makeTimelineEvent('PermissionRequest', undefined, 5000);
|
||||
expect(ev).not.toBeNull();
|
||||
expect(ev!.class).toBe('waiting');
|
||||
expect(ev!.toolName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns a TimelineEvent for Stop', () => {
|
||||
const ev = makeTimelineEvent('Stop', undefined, 7000);
|
||||
expect(ev).not.toBeNull();
|
||||
expect(ev!.class).toBe('done');
|
||||
});
|
||||
|
||||
it('returns a TimelineEvent for SessionEnd', () => {
|
||||
const ev = makeTimelineEvent('SessionEnd', undefined, 8000);
|
||||
expect(ev).not.toBeNull();
|
||||
expect(ev!.class).toBe('done');
|
||||
});
|
||||
|
||||
it('returns a TimelineEvent for UserPromptSubmit', () => {
|
||||
const ev = makeTimelineEvent('UserPromptSubmit', undefined, 6000);
|
||||
expect(ev).not.toBeNull();
|
||||
expect(ev!.class).toBe('user');
|
||||
});
|
||||
|
||||
it('returns a TimelineEvent for Notification', () => {
|
||||
const ev = makeTimelineEvent('Notification', undefined, 2000);
|
||||
expect(ev).not.toBeNull();
|
||||
expect(ev!.class).toBe('waiting');
|
||||
});
|
||||
|
||||
it('sanitizes control characters in toolName (SEC-H6)', () => {
|
||||
const ev = makeTimelineEvent('PostToolUse', 'Ba\x01sh\x1bscript', 1000);
|
||||
expect(ev).not.toBeNull();
|
||||
expect(ev!.toolName).toBe('Bashscript');
|
||||
});
|
||||
|
||||
it('truncates toolName to 200 characters (SEC-H6)', () => {
|
||||
const longName = 'a'.repeat(250);
|
||||
const ev = makeTimelineEvent('PostToolUse', longName, 1000);
|
||||
expect(ev).not.toBeNull();
|
||||
expect(ev!.toolName!.length).toBe(200);
|
||||
});
|
||||
|
||||
it('omits toolName from the result when toolName is undefined', () => {
|
||||
const ev = makeTimelineEvent('Stop', undefined, 1000);
|
||||
expect(ev).not.toBeNull();
|
||||
expect(ev!.toolName).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── appendEvent ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('appendEvent', () => {
|
||||
it('appends to an empty array', () => {
|
||||
const ev = makeEvent({ at: 100 });
|
||||
const result = appendEvent([], ev, 10);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe(ev);
|
||||
});
|
||||
|
||||
it('appends to a non-empty array', () => {
|
||||
const ev1 = makeEvent({ at: 100 });
|
||||
const ev2 = makeEvent({ at: 200 });
|
||||
const result = appendEvent([ev1], ev2, 10);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[1]).toBe(ev2);
|
||||
});
|
||||
|
||||
it('does not mutate the input array', () => {
|
||||
const original: readonly TimelineEvent[] = [makeEvent({ at: 1 })];
|
||||
const ev = makeEvent({ at: 2 });
|
||||
appendEvent(original, ev, 10);
|
||||
expect(original).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns a new array reference (immutability)', () => {
|
||||
const original: readonly TimelineEvent[] = [makeEvent({ at: 1 })];
|
||||
const ev = makeEvent({ at: 2 });
|
||||
const result = appendEvent(original, ev, 10);
|
||||
expect(result).not.toBe(original);
|
||||
});
|
||||
|
||||
it('evicts the oldest events when length exceeds maxLen', () => {
|
||||
const events: readonly TimelineEvent[] = [
|
||||
makeEvent({ at: 1 }),
|
||||
makeEvent({ at: 2 }),
|
||||
makeEvent({ at: 3 }),
|
||||
];
|
||||
const ev = makeEvent({ at: 4 });
|
||||
// maxLen = 3, so appending a 4th must evict the oldest (at=1)
|
||||
const result = appendEvent(events, ev, 3);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]!.at).toBe(2);
|
||||
expect(result[2]!.at).toBe(4);
|
||||
});
|
||||
|
||||
it('enforces the ring cap: append MAX+10 events → length ≤ MAX (SEC-M8)', () => {
|
||||
const MAX = 5;
|
||||
let events: readonly TimelineEvent[] = [];
|
||||
for (let i = 0; i < MAX + 10; i++) {
|
||||
events = appendEvent(events, makeEvent({ at: i }), MAX);
|
||||
}
|
||||
expect(events.length).toBeLessThanOrEqual(MAX);
|
||||
});
|
||||
|
||||
it('keeps only the newest events when trimming (oldest are evicted first)', () => {
|
||||
const MAX = 3;
|
||||
let events: readonly TimelineEvent[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
events = appendEvent(events, makeEvent({ at: i }), MAX);
|
||||
}
|
||||
// Should have the last 3 events: at = 7, 8, 9
|
||||
expect(events.map((e) => e.at)).toEqual([7, 8, 9]);
|
||||
});
|
||||
|
||||
it('handles maxLen of 1 (only the newest event survives)', () => {
|
||||
const ev1 = makeEvent({ at: 1 });
|
||||
const ev2 = makeEvent({ at: 2 });
|
||||
const result = appendEvent([ev1], ev2, 1);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.at).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── integration: each TimelineClass has at least one example ─────────────────
|
||||
|
||||
describe('TimelineClass coverage (AC-A4.4)', () => {
|
||||
const WHITELISTED_EVENTS: Array<{ event: string; toolName?: string; expectedClass: string }> = [
|
||||
{ event: 'PreToolUse', toolName: 'Bash', expectedClass: 'tool' },
|
||||
{ event: 'PostToolUse', toolName: 'Read', expectedClass: 'tool' },
|
||||
{ event: 'PermissionRequest', expectedClass: 'waiting' },
|
||||
{ event: 'Notification', expectedClass: 'waiting' },
|
||||
{ event: 'Stop', expectedClass: 'done' },
|
||||
{ event: 'SessionEnd', expectedClass: 'done' },
|
||||
{ event: 'UserPromptSubmit', expectedClass: 'user' },
|
||||
];
|
||||
|
||||
for (const { event, toolName, expectedClass } of WHITELISTED_EVENTS) {
|
||||
it(`${event} → class '${expectedClass}'`, () => {
|
||||
const ev = makeTimelineEvent(event, toolName, Date.now());
|
||||
expect(ev).not.toBeNull();
|
||||
expect(ev!.class).toBe(expectedClass);
|
||||
});
|
||||
}
|
||||
});
|
||||
436
test/setup-hooks.test.ts
Normal file
436
test/setup-hooks.test.ts
Normal 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
262
test/statusline.test.ts
Normal 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
190
test/sw-push.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,7 @@
|
||||
* (Phase 1#5), and the v0.6 openProject / home segmented-control wiring (P6).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
// ── Mock TerminalSession ──────────────────────────────────────────────────────
|
||||
let constructed = 0
|
||||
@@ -19,6 +19,11 @@ class FakeTerminalSession {
|
||||
claudeStatus = 'unknown'
|
||||
cwd: string | null = null
|
||||
pendingApproval = false
|
||||
pendingTool: string | undefined = undefined
|
||||
/** B4: which gate the held approval is — 'tool' (two buttons) or 'plan' (three). */
|
||||
pendingGate: 'tool' | 'plan' | null = null
|
||||
/** B2: latest statusLine telemetry (single source of truth). */
|
||||
telemetry: unknown = null
|
||||
/** Captured so tests can assert initialInput ends with \r (Finding 1). */
|
||||
initialInput: string | undefined = undefined
|
||||
connect = vi.fn()
|
||||
@@ -39,6 +44,7 @@ class FakeTerminalSession {
|
||||
onStatus?: (s: string) => void
|
||||
onActivity?: () => void
|
||||
onTitle?: (t: string) => void
|
||||
onTelemetry?: (t: unknown) => void
|
||||
}
|
||||
static instances: FakeTerminalSession[] = []
|
||||
constructor(opts: {
|
||||
@@ -48,6 +54,7 @@ class FakeTerminalSession {
|
||||
onStatus?: (s: string) => void
|
||||
onActivity?: () => void
|
||||
onTitle?: (t: string) => void
|
||||
onTelemetry?: (t: unknown) => void
|
||||
[key: string]: unknown
|
||||
}) {
|
||||
constructed += 1
|
||||
@@ -61,6 +68,37 @@ class FakeTerminalSession {
|
||||
|
||||
vi.mock('../public/terminal-session.js', () => ({ TerminalSession: FakeTerminalSession }))
|
||||
|
||||
// ── Mock the v0.7 panels (push / quick-reply / timeline / voice / gauge) ──────
|
||||
const renderTelemetryGauge = vi.fn()
|
||||
vi.mock('../public/preview-grid.js', () => ({ renderTelemetryGauge }))
|
||||
|
||||
const mountPushToggle = vi.fn()
|
||||
vi.mock('../public/push.js', () => ({ mountPushToggle }))
|
||||
|
||||
const quickReply: { onSend?: (d: string) => void } = {}
|
||||
const mountQuickReply = vi.fn((_c: HTMLElement, opts: { onSend: (d: string) => void }) => {
|
||||
quickReply.onSend = opts.onSend
|
||||
return { dispose: vi.fn() }
|
||||
})
|
||||
vi.mock('../public/quick-reply.js', () => ({ mountQuickReply }))
|
||||
|
||||
const timelineDispose = vi.fn()
|
||||
const mountTimeline = vi.fn(() => ({ dispose: timelineDispose }))
|
||||
vi.mock('../public/timeline.js', () => ({ mountTimeline }))
|
||||
|
||||
const voice: { onTranscript?: (t: string) => void; onInterim?: (t: string) => void } = {}
|
||||
const voiceStart = vi.fn()
|
||||
const voiceStop = vi.fn()
|
||||
const createVoiceInput = vi.fn(
|
||||
(onTranscript: (t: string) => void, opts?: { onInterim?: (t: string) => void }) => {
|
||||
voice.onTranscript = onTranscript
|
||||
voice.onInterim = opts?.onInterim
|
||||
return { start: voiceStart, stop: voiceStop, dispose: vi.fn(), isActive: () => false }
|
||||
},
|
||||
)
|
||||
const isSpeechSupported = vi.fn(() => true)
|
||||
vi.mock('../public/voice.js', () => ({ createVoiceInput, isSpeechSupported }))
|
||||
|
||||
// ── Mock the launcher (records visibility) ────────────────────────────────────
|
||||
const launcherVisible = { value: false }
|
||||
const mountLauncher = vi.fn(() => ({
|
||||
@@ -88,10 +126,21 @@ const { TabApp } = await import('../public/tabs.js')
|
||||
function makeHosts(): { paneHost: HTMLElement; tabBar: HTMLElement } {
|
||||
const paneHost = document.createElement('div')
|
||||
const tabBar = document.createElement('div')
|
||||
document.body.append(paneHost, tabBar)
|
||||
// #keybar must exist so the quick-reply row mounts directly above it (A3).
|
||||
const keybar = document.createElement('div')
|
||||
keybar.id = 'keybar'
|
||||
document.body.append(paneHost, tabBar, keybar)
|
||||
return { paneHost, tabBar }
|
||||
}
|
||||
|
||||
/** Stub fetch('/config/ui') → { allowAutoMode }. */
|
||||
function stubUiConfigFetch(allowAutoMode: boolean): void {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => ({ ok: true, json: async () => ({ allowAutoMode }) })),
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
constructed = 0
|
||||
FakeTerminalSession.instances = []
|
||||
@@ -99,6 +148,23 @@ beforeEach(() => {
|
||||
projectsVisible.value = false
|
||||
document.body.replaceChildren()
|
||||
localStorage.clear()
|
||||
renderTelemetryGauge.mockClear()
|
||||
mountPushToggle.mockClear()
|
||||
mountQuickReply.mockClear()
|
||||
mountTimeline.mockClear()
|
||||
timelineDispose.mockClear()
|
||||
createVoiceInput.mockClear()
|
||||
voiceStart.mockClear()
|
||||
voiceStop.mockClear()
|
||||
quickReply.onSend = undefined
|
||||
voice.onTranscript = undefined
|
||||
voice.onInterim = undefined
|
||||
// Default: server forbids 'auto' (loadUiConfig resolves to allowAutoMode=false).
|
||||
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('no fetch in test') }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('TabApp — v0.5 launcher chooser', () => {
|
||||
@@ -511,3 +577,216 @@ describe('TabApp — Home (⌂) button: reach the chooser from an open session',
|
||||
expect(seg.style.display).toBe('flex') // still shown, no crash
|
||||
})
|
||||
})
|
||||
|
||||
describe('TabApp — v0.7 panels mount once (A1 push / A3 quick-reply)', () => {
|
||||
it('mounts the 🔔 push toggle exactly once on construction (A1)', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
new TabApp(paneHost, tabBar)
|
||||
expect(mountPushToggle).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('mounts the quick-reply row directly above #keybar (A3)', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
new TabApp(paneHost, tabBar)
|
||||
expect(mountQuickReply).toHaveBeenCalledTimes(1)
|
||||
const qr = document.getElementById('quickreply')
|
||||
expect(qr).not.toBeNull()
|
||||
expect(qr?.nextElementSibling).toBe(document.getElementById('keybar'))
|
||||
})
|
||||
|
||||
it('quick-reply onSend routes bytes to the active session (A3-FR3)', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab()
|
||||
quickReply.onSend?.('yes\r')
|
||||
expect(FakeTerminalSession.instances[0]!.send).toHaveBeenCalledWith('yes\r')
|
||||
})
|
||||
|
||||
it('the push bell survives a tab-bar rebuild (re-parented, not re-mounted)', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab() // rebuild
|
||||
app.newTab() // rebuild again
|
||||
expect(mountPushToggle).toHaveBeenCalledTimes(1)
|
||||
expect(tabBar.querySelector('.push-host')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('TabApp — B2 telemetry gauge + A5 stuck badge (SP10/M4)', () => {
|
||||
it('renders the telemetry gauge from the session.telemetry single source of truth', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab()
|
||||
const session = FakeTerminalSession.instances[0]!
|
||||
const tele = { at: Date.now(), costUsd: 0.5, contextUsedPct: 40 }
|
||||
session.telemetry = tele
|
||||
renderTelemetryGauge.mockClear()
|
||||
session.cbs.onTelemetry?.(tele) // server pushed new telemetry
|
||||
expect(renderTelemetryGauge).toHaveBeenCalled()
|
||||
expect(renderTelemetryGauge.mock.calls.at(-1)![1]).toBe(tele)
|
||||
})
|
||||
|
||||
it('a stuck claudeStatus shows ⚠ + the claude-stuck class on a background tab (AC-A5.5)', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab() // tab 0
|
||||
app.newTab() // tab 1 active → tab 0 background
|
||||
const bg = FakeTerminalSession.instances[0]!
|
||||
bg.claudeStatus = 'stuck'
|
||||
bg.cbs.onClaudeStatus?.('stuck')
|
||||
const tab0 = tabBar.querySelectorAll('.tab')[0]!
|
||||
expect(tab0.classList.contains('claude-stuck')).toBe(true)
|
||||
expect(tab0.querySelector('.tab-claude')?.textContent).toBe('⚠')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TabApp — B4 plan-gate approval bar', () => {
|
||||
function openWithGate(gate: 'tool' | 'plan', tool?: string): FakeTerminalSession {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab()
|
||||
const session = FakeTerminalSession.instances.at(-1)!
|
||||
session.pendingApproval = true
|
||||
session.pendingGate = gate
|
||||
session.pendingTool = tool
|
||||
session.cbs.onClaudeStatus?.('waiting')
|
||||
return session
|
||||
}
|
||||
|
||||
it('a tool gate renders exactly two buttons (no regression, AC-B4.3)', () => {
|
||||
openWithGate('tool', 'Bash')
|
||||
const bar = document.getElementById('approvalbar')!
|
||||
expect(bar.style.display).toBe('flex')
|
||||
expect(bar.querySelectorAll('button')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('a plan gate maps three buttons to acceptEdits / default / reject (AC-B4.2)', () => {
|
||||
const session = openWithGate('plan')
|
||||
const bar = document.getElementById('approvalbar')!
|
||||
const click = (i: number): void => (bar.querySelectorAll('button')[i] as HTMLButtonElement).click()
|
||||
expect(bar.querySelectorAll('button')).toHaveLength(3)
|
||||
click(0) // Approve + Auto
|
||||
expect(session.approve).toHaveBeenLastCalledWith('acceptEdits')
|
||||
click(1) // Approve + Review
|
||||
expect(session.approve).toHaveBeenLastCalledWith('default')
|
||||
click(2) // Keep Planning
|
||||
expect(session.reject).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('TabApp — B4 permission-mode relay', () => {
|
||||
it('openProject with a non-default mode upgrades the launch command (AC-B4.1)', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.openProject('/p', 'repo', 'claude\r', 'plan')
|
||||
expect(FakeTerminalSession.instances.at(-1)!.initialInput).toBe(
|
||||
'claude --permission-mode plan\r',
|
||||
)
|
||||
})
|
||||
|
||||
it('openProject with default mode keeps the plain claude command (no regression)', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.openProject('/p', 'repo', 'claude\r', 'default')
|
||||
expect(FakeTerminalSession.instances.at(-1)!.initialInput).toBe('claude\r')
|
||||
})
|
||||
|
||||
it('availablePermissionModes hides "auto" until the server allows it (SEC-M5/AC-B4.4)', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar) // default fetch stub → allowAutoMode false
|
||||
expect(app.availablePermissionModes()).toEqual(['default', 'acceptEdits', 'plan'])
|
||||
})
|
||||
|
||||
it('availablePermissionModes includes "auto" once /config/ui allows it', async () => {
|
||||
stubUiConfigFetch(true)
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
await vi.waitFor(() => expect(app.availablePermissionModes()).toContain('auto'))
|
||||
})
|
||||
|
||||
it('loadDefaultMode downgrades stored "auto" to "default" when the server forbids it', () => {
|
||||
localStorage.setItem('web-terminal:permission-mode', 'auto')
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar) // allowAutoMode stays false
|
||||
expect(app.loadDefaultMode()).toBe('default')
|
||||
})
|
||||
|
||||
it('saveDefaultMode round-trips a valid mode; invalid stored value falls back', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.saveDefaultMode('plan')
|
||||
expect(app.loadDefaultMode()).toBe('plan')
|
||||
localStorage.setItem('web-terminal:permission-mode', 'garbage')
|
||||
expect(app.loadDefaultMode()).toBe('default')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TabApp — A4 timeline panel', () => {
|
||||
const SID = '44444444-4444-4444-8444-444444444444'
|
||||
|
||||
it('toggling 📜 mounts the timeline for the active session, then disposes on close', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.openSession(SID)
|
||||
const tlBtn = tabBar.querySelector('.tab-timeline') as HTMLButtonElement
|
||||
expect(tlBtn).not.toBeNull()
|
||||
|
||||
tlBtn.click() // open
|
||||
expect(mountTimeline).toHaveBeenCalledTimes(1)
|
||||
expect(document.getElementById('timeline-panel')!.style.display).toBe('block')
|
||||
|
||||
tlBtn.click() // close
|
||||
expect(timelineDispose).toHaveBeenCalled()
|
||||
expect(document.getElementById('timeline-panel')!.style.display).toBe('none')
|
||||
})
|
||||
|
||||
it('closing a tab with its timeline open disposes the polling handle', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.openSession(SID)
|
||||
;(tabBar.querySelector('.tab-timeline') as HTMLButtonElement).click() // open
|
||||
expect(mountTimeline).toHaveBeenCalled()
|
||||
app.closeTab(0)
|
||||
expect(timelineDispose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('TabApp — A2 push-to-talk voice', () => {
|
||||
it('handleVoiceTrigger("start") creates a recognizer and shows the interim overlay', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.handleVoiceTrigger('start')
|
||||
expect(createVoiceInput).toHaveBeenCalledTimes(1)
|
||||
expect(voiceStart).toHaveBeenCalled()
|
||||
expect(document.getElementById('voice-interim')!.style.display).toBe('block')
|
||||
})
|
||||
|
||||
it('a final transcript is sent to the active session as raw input (A2-FR1)', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab()
|
||||
app.handleVoiceTrigger('start')
|
||||
voice.onTranscript?.('list the files')
|
||||
expect(FakeTerminalSession.instances[0]!.send).toHaveBeenCalledWith('list the files')
|
||||
})
|
||||
|
||||
it('interim text updates the overlay; "stop" hides it (A2-FR2)', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.handleVoiceTrigger('start')
|
||||
voice.onInterim?.('hello wor')
|
||||
const overlay = document.getElementById('voice-interim')!
|
||||
expect(overlay.textContent).toBe('hello wor')
|
||||
app.handleVoiceTrigger('stop')
|
||||
expect(voiceStop).toHaveBeenCalled()
|
||||
expect(overlay.style.display).toBe('none')
|
||||
})
|
||||
|
||||
it('voice is a no-op when SpeechRecognition is unsupported (AC-A2.3)', () => {
|
||||
createVoiceInput.mockReturnValueOnce(null as never)
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
expect(() => app.handleVoiceTrigger('start')).not.toThrow()
|
||||
expect(voiceStart).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
305
test/telemetry-gauge.test.ts
Normal file
305
test/telemetry-gauge.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -456,3 +456,158 @@ describe('show()/refit() re-fit when visible', () => {
|
||||
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
526
test/timeline.test.ts
Normal 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
447
test/voice.test.ts
Normal 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
551
test/worktree-form.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user