fix: address review report across security, architecture, quality, tests

Implements the fixes from docs/REVIEW_REPORT.md (4-agent parallel review).
typecheck clean; 341 tests pass (16 files, +113); build:web ok; coverage
thresholds (80%) enforced in vitest.config.ts.

Critical:
- multi-device approval race: release held approval only when the last
  client detaches (closing one mirror no longer cancels another's prompt)
- unbounded session creation (DoS): Config.maxSessions cap (env MAX_SESSIONS),
  enforced in manager via the existing M4 exit(-1) path
- signal-handler leak: named SIGINT/SIGTERM/uncaughtException refs removed in close()
- terminal-session initialInput timer tracked + cleared on dispose
- tabs.addEntry null-as-cast type hole removed (build session before entry)

Should-fix:
- security-headers middleware + Origin/CSRF guard on DELETE /live-sessions[/:id]
- history.ts converted to fs/promises (async /sessions handler)
- removed dead clientDims map + blur protocol message end-to-end
- per-connection WS message rate limit (Config.maxMsgsPerSec)
- /sessions behavior kept; documented as accepted LAN risk (TECH_DOC §7)

Tests:
- new tmux / preview-grid / terminal-session (jsdom) / tabs (jsdom) suites
- extended history/config/manager/integration coverage incl. regressions

Hygiene:
- parsePositiveInt -> parseNonNegativeInt; ALLOWED_ORIGINS scheme validation
- log-injection sanitize; isLoopback handles 127.0.0.0/8 + IPv4-mapped
- operational constants moved into Config
- extracted public/preview-grid.ts (DRY launcher/manage)
- doc sweeps: ARCHITECTURE §8 runtime-handle exception, stale comments
This commit is contained in:
Yaojia Wang
2026-06-20 18:27:45 +02:00
parent 97d57326fd
commit d22dcd24f7
30 changed files with 2900 additions and 400 deletions

View File

@@ -79,7 +79,7 @@ Data flow: `keypress → xterm onData → WS → pty.write() → shell stdin`, a
- `attach(null)` → spawn a new PTY + shell; PTY output goes into a ~2MB **ring buffer** and (if a WS is attached) forwards live.
- `attach(sessionId)` → look up the session, **replay the ring buffer**, then resume the live stream. This is what makes "refresh the page and the Claude session is still there" work.
- WS close → `detach` one client (PTY keeps running), not kill; the idle clock starts only when the **last** client leaves.
- **Multi-device sharing (v0.4):** a session may have **many concurrent WS clients** — a new attach **JOINS (mirror)**, it does **not** kick. Output/exit/status broadcast to all; any client can type (shared control). **PTY sizing = latest-writer-wins:** the device that most recently fit/focused drives the size, so whichever device you're actively using is full-screen (a shared PTY can only be one size; min-sizing letterboxed the bigger screen). Attach/detach/`blur` never resize — the active device keeps its size; the frontend re-sends dims on pane-show and window-focus so switching devices reclaims full-screen. This relaxes the original "one WS per session" invariant (#5). `GET /live-sessions` lists running sessions so any device **auto-shows them as tabs**; `?join=<id>` and the 🔗 share-QR open a specific shared session; the **🗂 manage page** (`/manage.html`) is a full-page **grid of live preview thumbnails** — each card renders the session's current screen via a read-only xterm (fed by `GET /live-sessions/:id/preview``RingBuffer.tail()`, no attach) so you can see what each session is doing, with open/kill (`DELETE /live-sessions[/:id]`).
- **Multi-device sharing (v0.4):** a session may have **many concurrent WS clients** — a new attach **JOINS (mirror)**, it does **not** kick. Output/exit/status broadcast to all; any client can type (shared control). **PTY sizing = latest-writer-wins:** the device that most recently fit/focused drives the size, so whichever device you're actively using is full-screen (a shared PTY can only be one size; min-sizing letterboxed the bigger screen). Attach/detach never resize — the active device keeps its size; the frontend re-sends dims on pane-show and window-focus so switching devices reclaims full-screen. (The earlier `blur` size-vote message was removed in the min→latest-writer pivot.) This relaxes the original "one WS per session" invariant (#5). `GET /live-sessions` lists running sessions so the **v0.5 home launcher (session chooser)** shows them as live preview thumbnails to pick from; `?join=<id>` and the 🔗 share-QR open a specific shared session; the **🗂 manage page** (`/manage.html`) is a full-page **grid of live preview thumbnails** — each card renders the session's current screen via a read-only xterm (fed by `GET /live-sessions/:id/preview``RingBuffer.tail()`, no attach) so you can see what each session is doing, with open/kill (`DELETE /live-sessions[/:id]`).
- Orphan reclaim: detached longer than `IDLE_TTL` (default 24h) **with no new output since detach** → reclaim. (node-pty can't reliably detect a foreground child, so liveness is approximated via last-output time — see ARCHITECTURE §3.5 / M3.) On server exit, `pty.kill()` all sessions (no cross-restart persistence in v0.1; tmux backend is a v0.2 idea).
Represent session state as immutable snapshots (id/start-time fixed at creation); hold mutable runtime handles (pty/ws) separately.

View File

@@ -421,11 +421,11 @@ client → {resize, cols, rows} // 独立消息,不混进 input
1. 服务端不解析 ANSI/终端语义,PTY 字节是不透明 blob(但 RingBuffer 淘汰须按 chunk/码点边界,不可任意字节切割,M2)。
2. WS 断开 **永不** kill PTY —— 只 detach;`ws.send` 失败也只 detach 该 ws,不动 PTY(M5)。
3. `parseClientMessage` 永不抛异常,非法输入走 `ParseResult.ok=false`
4. 会话 meta 不可变;运行时句柄(ws/detachedAt/lastOutputAt/exitedAt)单独可变
5. 一个会话同一时刻最多一个附着 ws;转发前先重置 `attachedWs` 指针再 close 旧 ws
4. 会话 meta(`SessionMeta`:id/createdAt/shellPath)全 `readonly` 不可变;**运行时句柄字段是明确的例外,允许就地变更**——`clients`(Set)、`detachedAt``lastOutputAt``exitedAt`/`exitCode``claudeStatus``cwd` 是每会话的可变运行时状态(H6)。这是对全局"绝不 mutate"风格的**有意例外**:不可变快照与可变句柄被刻意分离持有,在每次按键时复制一个 Set 是纯粹的浪费。新增运行时状态时,把它放在 `Session`(可变区)而非 `SessionMeta`
5. (v0.4 放宽)一个会话可有**多个**并发附着 ws(多设备镜像共享):新 attach **JOIN(不踢)**;output/exit/status 广播给所有 client;任意 client 可输入(共享控制);PTY 尺寸 **latest-writer-wins**(最近 fit/focus 的设备驱动)。原"同一时刻最多一个 ws"已不再成立
6. 任何 `ws.send` 前必须 `readyState === OPEN`(M5)。
7. Origin 校验在 upgrade 阶段完成(`noServer` + `handleUpgrade`),失败 401,不进入 WS 逻辑;路径限 `/term`(L3)。
8. 无硬编码:所有可变参数走 `config.ts`(端口、shell、TTL、scrollback、maxPayload、WS 路径、allowedOrigins)。
7. Origin 校验在 upgrade 阶段完成(`noServer` + `handleUpgrade`),失败 401,不进入 WS 逻辑;路径限 `/term`(L3)。**状态变更的 HTTP 路由(`DELETE /live-sessions[/:id]`)也复用同一 Origin 白名单做 CSRF 守卫(异源/缺失 → 403)。**
8. 无硬编码:所有可变参数走 `config.ts`(端口、shell、TTL、scrollback、maxPayload、WS 路径、allowedOrigins、maxSessions、maxMsgsPerSec、permTimeoutMs、reapIntervalMs、previewBytes)。
9. `input.data` 原样透传,不做内容过滤。
10. PTY 退出后 `writeInput`/`resize` 静默忽略;exitedAt 会话保留至被 attach 回放或 reapIdle 回收(L1/L4)。
11. 服务端不假设前端已防抖:`resize` 在服务端做幂等(值未变则跳过);WS 帧受 `maxPayload` 上限约束(L5)。

View File

@@ -41,7 +41,29 @@
- **下一步(交用户)**: 真手机/另一台电脑开 `http://<你IP>:3000` 实测 **F4**(局域网可用)与 **F7**(Esc/Shift+Tab/方向键等触摸键生效)。
- **阻塞**: 无(F4/F7 非阻塞,属需物理设备的人工验收)。
- **次要 tech-debt**: main.ts 1 处 `@ts-ignore`(CSS import);未来加 `*.css` d.ts。
- **最后更新**: 2026-06-17
- **最后更新**: 2026-06-20(并入 review 全面修复,见下方条目)
---
## 维护性修复条目 (Review Fixes)
### 2026-06-20 · 按 REVIEW_REPORT 逐项修复(安全/架构/质量/测试)
- **状态**: `[x]` DONE(typecheck 干净 · **341 测试全绿**(16 文件,自 228 起 +113)· `build:web` 通过 · 覆盖率 90.3/80.7/87.6/92.4 ≥80 阈值已在 `vitest.config.ts` 强制)。
- **来源**: 4 个 agent 并行 review 产出 `docs/REVIEW_REPORT.md`;用户决定修全部四档,`/sessions` 保持现状仅加说明,`clientDims`/`blur` 死状态彻底删除。计划见 `~/.claude/plans/piped-marinating-parrot.md`
- **🔴 关键**:
- 多设备审批竞态(`server.ts` ws.close):仅当最后一个 client 离开时才 `resolvePending` —— 关一个镜像不再取消另一台正在看的待批准。
- 会话无上限 DoS:新增 `Config.maxSessions`(默认 50,env `MAX_SESSIONS`)+ `manager.assertUnderSessionCap()`,超限抛错复用 M4 的 exit(-1) 路径。
- 信号处理器泄漏:`onSignal`/`onUncaught` 提具名引用,`close()``process.off` 全移除(去掉测试 `setMaxListeners(50)` 遮掩)。
- 前端 `initialInput` 700ms 定时器:跟踪 `initialInputTimer``dispose()` 清理、`disposed` 守卫、`INITIAL_INPUT_DELAY_MS` 常量。
- `tabs.ts addEntry``null as unknown as TerminalSession` 类型洞:先建 session 再建 entry。
- **🟠 应修**: 安全头中间件(无 helmet)+ `DELETE /live-sessions[/:id]` Origin/CSRF 守卫(403);`history.ts``fs/promises`(`listSessions` 异步,`/sessions` await);**彻底删除 `clientDims`+`blur`**(types/session/protocol/server/manager 注释/terminal-session.hide + 相关测试);WS 连接级限频(`maxMsgsPerSec` 默认 2000,超限丢帧不断连);`/sessions` 保持行为,加注释 + `TECH_DOC §7` 记录已接受风险。
- **🟡 测试**: 新增 `test/{tmux,preview-grid,terminal-session,tabs}.test.ts`(后两者 jsdom + mock WebSocket);扩 `history/config/manager/integration` 覆盖 killById/handleHookEvent/maxSessions/resolveUseTmux/live-sessions/preview-404/approve-reject/origin-guard/限频/信号泄漏回归。
- **⚪ 卫生**: `parsePositiveInt``parseNonNegativeInt`;ALLOWED_ORIGINS scheme 校验;`server.ts` 日志注入 sanitize + `isLoopback` 健壮化(127.0.0.0/8 + IPv4-mapped);运维常量(`PERM_TIMEOUT_MS`/`REAP_INTERVAL_MS`/`PREVIEW_BYTES`)入 Config;抽 `public/preview-grid.ts` 消除 launcher/manage 重复(类型复用 `LiveSessionInfo`);ARCHITECTURE §8 记录运行时句柄可变例外(H6)+ #5/#7/#8 更新;CLAUDE.md「auto-show as tabs」→ v0.5 launcher;「min across clients」陈旧注释清扫。
- **验证**: `npm run typecheck` PASS · `npx vitest run` 341/341 · `npm run build:web` OK · `npx vitest run --coverage` 退出 0(阈值 80×4)。
- **决策 / 偏离**: 覆盖率 `include` 收窄到 `src/**` + 四个有逻辑的前端模块,排除纯 DOM 接线/入口胶水(E2E 范畴),**未下调任何阈值**。未把审批状态机搬进 manager(报告 #10)—— close-handler 修法已正确最小,`server.ts` 抽 routes 留作可选后续。
- **遗留 / 待办**: 真机验收 F4/F7(同 v0.1 遗留);可选 server.ts 抽 `http/routes.ts`;胶水文件若要纳入覆盖率需补 E2E。
- **commit**: (本次提交)
---

84
docs/REVIEW_REPORT.md Normal file
View File

@@ -0,0 +1,84 @@
# Web-Terminal — Comprehensive Review Report
**Date:** 2026-06-20 · **Branch:** main (`97d5732`) · **Scope:** full project (v0.1v0.5)
**Method:** 4 specialized agents in parallel — security, architecture, code quality, test coverage.
**Ground truth:** `tsc` typecheck clean (strict + `noUncheckedIndexedAccess`) · **228/228 tests pass** (12 files) · `npm audit` 0 vulns · ESLint not configured · coverage tooling configured (v8) but **`@vitest/coverage-v8` not installed** and **no threshold enforced**.
---
## Verdict
The project is **fundamentally sound**. The load-bearing design invariant — PTY lifecycle decoupled from WebSocket lifecycle — is correctly implemented, the byte-shuttle separation is intact (zero ANSI parsing on the server), and the v0.4 multi-device relaxation of invariant #5 is consistent end-to-end. Backend unit tests are genuinely high quality.
**No CRITICAL issues.** The notable items are: a real multi-device approval race, unbounded session creation (DoS), a history-leak endpoint, a handful of HIGH code-hygiene bugs (signal-handler leak, untracked timer, type hole), and a structural test gap (entire frontend + several backend paths untested, no enforced coverage).
| Dimension | Grade | One-line |
|---|---|---|
| Security | B | Core CSWSH defense solid; gaps are DoS + a LAN info-leak endpoint + missing headers |
| Architecture | A | Design honored on every invariant; one real race + documentation/dead-state drift |
| Code quality | B | Clean types, but 6 HIGH hygiene bugs (resource/timer/cast) to fix before "done" |
| Test coverage | C+ | Backend strong; frontend 0%, no enforced threshold, several backend paths untested |
---
## Priority Action List (deduped, ranked)
### 🔴 Fix before shipping further
1. **Multi-device approval race** (`server.ts:332`) — on `ws.close`, `resolvePending(boundSessionId)` fires for *every* disconnecting client of a shared session, so closing one mirror **cancels a pending approval another device is looking at**. Release only on last detach (`session.clients.size === 0`), and move approval state into the manager. *(Arch 5a — directly undermines the headline v0.4 feature.)*
2. **Unbounded session creation → DoS** (`manager.ts:91`) — repeated `attach{sessionId:null}` spawns unlimited PTYs + 2 MB ring buffers each; no cap. Add `MAX_SESSIONS` (default ~20) enforced in `handleAttach`; reject over-limit with an `exit` frame. *(Sec H1.)*
3. **Signal-handler leak** (`server.ts:364`) — `SIGINT`/`SIGTERM`/`uncaughtException` handlers added every `startServer()`, never removed in `close()` (tests paper over it with `setMaxListeners(50)`). Capture refs, `process.off()` in `close()`. *(CQ H1.)*
4. **Untracked timer fires after `dispose()`** (`terminal-session.ts:250`) — the 700 ms `initialInput` `setTimeout` is never tracked/cancelled; survives only by the WS-readyState guard, not a `disposed` check. Track it, check `this.disposed`, clear on dispose. *(CQ H2.)*
5. **`null as unknown as TerminalSession` type hole** (`tabs.ts:150`) — entry pushed to `this.tabs` with a null session typed non-null; if `new TerminalSession()` throws, later `refreshTab` crashes. Build `session` first, then the entry. *(CQ H3.)*
### 🟠 Should fix
6. **CSRF on destructive routes + missing headers**`DELETE /live-sessions[/:id]` is state-changing with no Origin/CSRF check (WS upgrade checks Origin; plain HTTP routes don't), and no `X-Frame-Options`/CSP enables clickjacking of Kill-All. Add the Origin check (or rely on a forced JSON/preflight) to mutating routes; add a security-headers middleware. *(Arch 5b + Sec H2 — same root.)*
7. **`/sessions` leaks Claude history to all LAN devices** (`history.ts:80`, `server.ts:114`) — returns cwd paths + first 120 chars of first prompt + resumable session UUIDs, unauthenticated. Gate behind the same loopback check used for `/hook`, or remove. *(Sec H3.)*
8. **Sync `fs` blocks the event loop** (`history.ts:66`) — `readdirSync`/`statSync`/`readSync` in the `GET /sessions` handler stall all WS/PTY traffic. Convert to `fs.promises` + async handler. *(CQ H4.)*
9. **Dead `clientDims` state from the min→latest-writer pivot** (`types.ts:155`, `session.ts:196`) — map is written/deleted but **never read**; `clearClientDims` + the `blur` protocol message are now no-ops. Delete them (or document as no-ops). *(Arch 2a.)*
10. **Slim `server.ts` (≈408 lines)** back toward thin wiring — extract `http/routes.ts` and move the held-approval state machine into the manager. This is the root cause of #1. *(Arch debt.)*
11. **Per-connection WS message rate limit** (`server.ts:249`) — post-attach `input`/`resize` floods saturate CPU/IO; `maxPayload` caps size, not frequency. Add a leaky-bucket per socket. *(Sec M3.)*
### 🟡 Test gaps (raise the floor)
12. Install `@vitest/coverage-v8`, add `thresholds: { lines/functions/branches/statements: 80 }` to `vitest.config.ts`, wire `--coverage` into CI. Today the 80% rule is unmeasured.
13. **Frontend is 0% covered**`terminal-session.ts` (400 lines: reconnect state machine, `buildWsUrl` scheme selection, `hide()``blur`, `dispose()`), `tabs.ts` (last-close→launcher v0.5 invariant), `launcher.ts`, `manage.ts`. Add jsdom config + mock `WebSocket`.
14. **Untested backend paths:** `manager.killById`/`handleHookEvent`, `history.listSessions` (fs traversal), `tmux.hasSession`/`killSession`, `config.resolveUseTmux`, `isLoopback`, and the `/live-sessions*` + `blur`/`approve`/`reject` routes in `server.ts`.
### ⚪ Lower priority / hygiene
- **DRY:** `launcher.ts` & `manage.ts` are ~80% duplicated (card factory, `relTime`, `statusText`, preview fetch, re-declared `LiveSession`/`Preview`). Extract a shared `preview-grid.ts`; import `LiveSessionInfo` from `src/types.ts` instead of re-declaring. *(Arch 6a + CQ M1.)*
- **Stale comments/docs:** "min across clients" JSDoc in `manager.ts:21`, `session.ts:179`, `types.ts:153`; duplicate JSDoc on `setClientDims`; CLAUDE.md still says "auto-show as tabs" (v0.5 uses the launcher chooser). Sweep to match latest-writer-wins. *(Arch 2b/6b + CQ M6.)*
- **`ALLOWED_ORIGINS` scheme validation** (`config.ts:107`) — reject non-http(s) entries to avoid exotic-scheme cross-match. *(Sec M1.)*
- **Log injection** (`server.ts:260`, `protocol.ts:64`) — sanitize/truncate the user-controlled `type` field before logging. *(Sec M2.)*
- **`cwd` hardening** (`protocol.ts:144`) — `path.resolve()` + comment; within current threat model informational only. *(Sec L2 / CQ H5.)*
- **`isLoopback` robustness** (`server.ts:65`) — misses `127.0.0.0/8` and hex IPv4-mapped forms; fine on macOS/Linux today, brittle behind proxies. *(Sec L1.)*
- Misc CQ: `parsePositiveInt` accepts 0 (rename `parseNonNegativeInt`); `isConnecting` double-reset in `error` handler; `confirm()` blocks in `manage.ts`; magic `700` ms; operational constants (`PERM_TIMEOUT_MS`, `REAP_INTERVAL_MS`, `PREVIEW_BYTES`) not in `Config` despite invariant #8.
---
## Note: the immutability tension (intentional)
The code reviewer flagged in-place mutation of `Session` runtime fields (`detachedAt`, `lastOutputAt`, `claudeStatus`, `clients` Set) as a violation of the project's "never mutate" rule (CQ H6). The architect's read — which the docs support — is that this is **by design**: `SessionMeta` is fully `readonly` and immutable; mutable *runtime handles* are deliberately held separately (CLAUDE.md, ARCHITECTURE invariant #4), and replacing a Set on every keystroke would be wasteful. **Recommendation:** keep the design, but resolve the contradiction in writing — carve out the runtime-state exception in the coding-style rule, or extract a `SessionState` object replaced atomically. Don't silently leave a stated rule contradicted.
---
## What's working well (don't regress)
- **CSWSH defense** (`origin.ts`) is correct: empty/undefined Origin rejected; scheme+host+port all matched; `noServer:true` single-upgrade-handler can't be raced. ✅
- **Byte-shuttle invariant** intact — server does zero ANSI parsing; `input.data` passed verbatim; terminal semantics live only in the browser. ✅
- **Ring buffer (M2)** is the cleanest module: real UTF-8 byte accounting, whole-chunk eviction, `\x1b[0m` reset on replay — no codepoint/escape can be cut. ✅
- **Session decoupling** verified by tests against a live shell: PTY survives WS disconnect, reconnect replays scrollback. ✅
- All child processes use `execFileSync` with arg arrays — **no command injection** via tmux names. ✅
- Backend unit tests use AAA, inject deterministic timestamps, mock at the `node-pty` boundary, and cover named invariants (M2M7, L1L4). ✅

View File

@@ -240,6 +240,12 @@ Origin 校验是无认证方案里**唯一不能省**的防线,因为攻击者
否则 F4(局域网设备访问)会被 F9(Origin 校验)误判 401——二者打架。实现见 ARCHITECTURE §3.1/§3.3。
前端 WS 的 scheme 随页面协议(`https→wss`),避免 Tailscale/TLS(HTTPS)部署下 `ws://` 被 mixed-content 拦截。
**已知接受风险 —— `/sessions` 端点(O2 历史浏览)**:该路由对局域网内任意设备**无鉴权**返回最近的 Claude Code 会话信息:每个会话的 `cwd`、首条 prompt 的前 ~120 字、以及可 `claude --resume` 的会话 UUID。这是一处真实的信息泄露,但与本应用的威胁模型一致——本应用本身就把完整 shell 交给任何能访问端口的人(无 auth、仅局域网、永不公网)。故**保持现状不加门禁**,通过 Tailscale 部署收敛网络面即可。(此前也评估过用 `/hook` 的 loopback 检查门禁它,但那会使局域网设备无法浏览历史,与 F4 冲突。)代码处有同义注释。
**状态变更路由的 CSRF 守卫**:`DELETE /live-sessions[/:id]`(manage 页 kill/批量 kill)会改变服务端状态,而普通 HTTP 路由不像 WS 升级那样天然有 Origin 检查。故这两条 DELETE 复用同一 `allowedOrigins` 白名单做 Origin 守卫——异源/缺失 Origin → 403——挡住恶意页面无预检触发 Kill-All。配套的安全响应头(`X-Frame-Options: DENY``X-Content-Type-Options: nosniff`、保守 CSP)阻止点击劫持。
**连接级限频**:`maxPayload` 只约束单帧**大小**;另加每连接漏桶限频(`MAX_MSGS_PER_SEC`,默认 2000)约束单连接帧**频率**,超限丢帧(不 close,避免误杀合法突发)+ 节流日志。
---
## 8. 功能清单

775
package-lock.json generated
View File

@@ -23,7 +23,9 @@
"@types/node": "^25.9.3",
"@types/qrcode": "^1.5.6",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^4.1.9",
"esbuild": "^0.28.1",
"jsdom": "^29.1.1",
"tsx": "^4.22.4",
"typescript": "^6.0.3",
"vitest": "^4.1.9"
@@ -32,6 +34,270 @@
"node": ">=18"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@csstools/css-calc": "^3.2.0",
"@csstools/css-color-parser": "^4.1.0",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/generational-cache": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
"integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.8.tgz",
"integrity": "sha512-3chWb7PRLijpJpPIKkDxdu6IBeO5MrFACND57On0j8OPpc0wZibcGc3xAHrSEbOx/KDRyMHoIxGn0w1PhXMYHw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.0.2",
"@csstools/css-calc": "^3.2.1"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz",
"integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@@ -508,6 +774,34 @@
"node": ">=18"
}
},
"node_modules/@exodus/bytes": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
@@ -515,6 +809,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
@@ -969,6 +1274,37 @@
"@types/node": "*"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz",
"integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.9",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.2.0",
"magicast": "^0.5.2",
"obug": "^2.1.1",
"std-env": "^4.0.0-rc.1",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.9",
"vitest": "4.1.9"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
}
},
"node_modules/@vitest/expect": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
@@ -1156,6 +1492,28 @@
"node": ">=12"
}
},
"node_modules/ast-v8-to-istanbul": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz",
"integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"estree-walker": "^3.0.3",
"js-tokens": "^10.0.0"
}
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/body-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
@@ -1326,6 +1684,34 @@
"node": ">=6.6.0"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1352,6 +1738,13 @@
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -1412,6 +1805,19 @@
"node": ">= 0.8"
}
},
"node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -1721,6 +2127,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -1745,6 +2161,26 @@
"node": ">= 0.4"
}
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -1805,12 +2241,106 @@
"node": ">=8"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/js-tokens": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
"version": "29.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
"@exodus/bytes": "^1.15.0",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.3.5",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
"undici": "^7.25.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.1",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -2084,6 +2614,16 @@
"node": ">=8"
}
},
"node_modules/lru-cache": {
"version": "11.5.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -2094,6 +2634,34 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/magicast": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz",
"integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.3",
"@babel/types": "^7.29.0",
"source-map-js": "^1.2.1"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -2103,6 +2671,13 @@
"node": ">= 0.4"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
@@ -2282,6 +2857,19 @@
"node": ">=6"
}
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -2388,6 +2976,16 @@
"node": ">= 0.10"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
@@ -2453,6 +3051,16 @@
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
@@ -2515,6 +3123,32 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
@@ -2710,6 +3344,26 @@
"node": ">=8"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -2754,6 +3408,26 @@
"node": ">=14.0.0"
}
},
"node_modules/tldts": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.3.tgz",
"integrity": "sha512-A3BDQBeeukYPzB4QdQ1DtdlUmp4x2OCH8n5UVhEWbyANxNep8GavottKzd1xYKFJKjUgMyPT7EzOfnBO55s8Sg==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.3"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.3.tgz",
"integrity": "sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw==",
"dev": true,
"license": "MIT"
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -2763,6 +3437,32 @@
"node": ">=0.6"
}
},
"node_modules/tough-cookie": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -2835,6 +3535,16 @@
"node": ">=14.17"
}
},
"node_modules/undici": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
@@ -3028,6 +3738,54 @@
}
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
@@ -3092,6 +3850,23 @@
}
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",

View File

@@ -34,7 +34,9 @@
"@types/node": "^25.9.3",
"@types/qrcode": "^1.5.6",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^4.1.9",
"esbuild": "^0.28.1",
"jsdom": "^29.1.1",
"tsx": "^4.22.4",
"typescript": "^6.0.3",
"vitest": "^4.1.9"

View File

@@ -5,28 +5,20 @@
* start screen lists the host's running sessions as live preview thumbnails
* (read-only xterm, same as the manage page) so the user picks which to open —
* or starts a new one. Sessions persist server-side, so opening one replays its
* full scrollback.
* full scrollback. The thumbnail card + preview plumbing is shared with the
* manage page via public/preview-grid.ts (DRY).
*/
import { Terminal } from '@xterm/xterm'
interface LiveSession {
id: string
createdAt: number
clientCount: number
status: 'working' | 'waiting' | 'idle' | 'unknown'
exited: boolean
cwd: string | null
cols: number
rows: number
}
interface Preview {
id: string
cols: number
rows: number
data: string
}
import type { LiveSessionInfo } from '../src/types.js'
import {
el,
relTime,
makePreviewCard,
updatePreviewCard,
loadPreviewInto,
fetchLiveSessions,
type PreviewCard,
} from './preview-grid.js'
export interface LauncherHooks {
onOpen: (id: string) => void
@@ -36,45 +28,6 @@ export interface LauncherHooks {
const THUMB_W = 320
const THUMB_MAX_H = 200
const REFRESH_MS = 5000
const CLEAR = '\x1b[2J\x1b[3J\x1b[H\x1b[0m'
const THEME = { background: '#0e0f13', foreground: '#e7e8ec', cursor: '#0e0f13' }
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
}
function relTime(ms: number): string {
const s = Math.max(0, (Date.now() - ms) / 1000)
if (s < 60) return `${Math.floor(s)}s`
if (s < 3600) return `${Math.floor(s / 60)}m`
if (s < 86400) return `${Math.floor(s / 3600)}h`
return `${Math.floor(s / 86400)}d`
}
function statusText(s: LiveSession['status']): string {
return s === 'working' ? '⚙ working' : s === 'waiting' ? '⏳ waiting' : s === 'idle' ? '✓ idle' : '·'
}
function sessionName(s: LiveSession): string {
return s.cwd ? (s.cwd.split('/').filter(Boolean).pop() ?? s.cwd) : s.id.slice(0, 8)
}
interface Card {
el: HTMLElement
term: Terminal
inner: HTMLElement
thumb: HTMLElement
watch: HTMLElement
status: HTMLElement
meta: HTMLElement
}
export interface Launcher {
setVisible(v: boolean): void
@@ -101,77 +54,15 @@ export function mountLauncher(host: HTMLElement, hooks: LauncherHooks): Launcher
const grid = el('div', 'mg-grid')
root.append(grid)
const cards = new Map<string, Card>()
const cards = new Map<string, PreviewCard>()
let timer: ReturnType<typeof setInterval> | null = null
function fit(card: Card): void {
const w = card.inner.offsetWidth
const h = card.inner.offsetHeight
if (w === 0 || h === 0) return
const scale = Math.min(1, THUMB_W / w)
card.inner.style.transform = `scale(${scale})`
card.thumb.style.height = `${Math.min(h * scale, THUMB_MAX_H)}px`
}
function makeCard(s: LiveSession): Card {
const cardEl = el('div', 'mg-card')
const h = el('div', 'mg-card-head')
const title = el('span', 'mg-name', sessionName(s))
const status = el('span', `mg-status mg-${s.status}`, statusText(s.status))
const watch = el('span', s.clientCount > 0 ? 'mg-watch live' : 'mg-watch', `👁 ${s.clientCount}`)
h.append(title, status, watch)
const thumb = el('div', 'mg-thumb')
const inner = el('div', 'mg-thumb-inner')
thumb.append(inner)
thumb.addEventListener('click', () => hooks.onOpen(s.id))
const meta = el('div', 'mg-meta')
const actions2 = el('div', 'mg-actions')
const open = el('button', 'mg-open', 'Open ↗')
open.addEventListener('click', () => hooks.onOpen(s.id))
actions2.append(open)
const term = new Terminal({
cols: Math.max(2, s.cols),
rows: Math.max(2, s.rows),
disableStdin: true,
cursorBlink: false,
fontFamily: 'Menlo, Consolas, monospace',
fontSize: 12,
scrollback: 0,
theme: THEME,
})
term.open(inner)
cardEl.append(h, thumb, meta, actions2)
return { el: cardEl, term, inner, thumb, watch, status, meta }
}
async function loadPreview(id: string, card: Card): Promise<void> {
try {
const res = await fetch(`/live-sessions/${id}/preview`)
if (!res.ok) return
const p = (await res.json()) as Preview
if (card.term.cols !== Math.max(2, p.cols) || card.term.rows !== Math.max(2, p.rows)) {
card.term.resize(Math.max(2, p.cols), Math.max(2, p.rows))
}
card.term.reset()
card.term.write(CLEAR + p.data, () => requestAnimationFrame(() => fit(card)))
} catch {
/* preview is best-effort */
}
function makeCard(s: LiveSessionInfo): PreviewCard {
return makePreviewCard(s, { onOpen: (id) => hooks.onOpen(id) })
}
async function refresh(): Promise<void> {
let sessions: LiveSession[] = []
try {
const res = await fetch('/live-sessions')
const data: unknown = await res.json()
if (Array.isArray(data)) sessions = data as LiveSession[]
} catch {
sessions = []
}
const sessions = await fetchLiveSessions()
sub.textContent = sessions.length
? `${sessions.length} running on this host — pick one to open`
@@ -186,12 +77,9 @@ export function mountLauncher(host: HTMLElement, hooks: LauncherHooks): Launcher
cards.set(s.id, card)
grid.append(card.el)
}
card.status.className = `mg-status mg-${s.status}`
card.status.textContent = statusText(s.status)
card.watch.className = s.clientCount > 0 ? 'mg-watch live' : 'mg-watch'
card.watch.textContent = `👁 ${s.clientCount}`
updatePreviewCard(card, s)
card.meta.textContent = `${s.cwd ?? 'unknown dir'} · ${s.cols}×${s.rows} · ${relTime(s.createdAt)} old`
void loadPreview(s.id, card)
void loadPreviewInto(s.id, card, THUMB_W, THUMB_MAX_H)
}
for (const [id, card] of cards) {
if (!seen.has(id)) {

View File

@@ -8,86 +8,30 @@
*
* Previews use GET /live-sessions/:id/preview (the scrollback tail) — they do
* NOT open a WS / attach, so they don't inflate watcher counts or keep sessions
* alive.
* alive. The card + preview plumbing is shared with the launcher via
* public/preview-grid.ts (DRY).
*/
import { Terminal } from '@xterm/xterm'
import '@xterm/xterm/css/xterm.css'
interface LiveSession {
id: string
createdAt: number
clientCount: number
status: 'working' | 'waiting' | 'idle' | 'unknown'
exited: boolean
cwd: string | null
cols: number
rows: number
}
interface Preview {
id: string
cols: number
rows: number
data: string
}
import type { LiveSessionInfo } from '../src/types.js'
import {
el,
relTime,
makePreviewCard,
updatePreviewCard,
loadPreviewInto,
fitThumb,
fetchLiveSessions,
type PreviewCard,
} from './preview-grid.js'
const REFRESH_MS = 4000
const THUMB_W = 360 // card thumbnail width in px
const THUMB_MAX_H = 220
const CLEAR = '\x1b[2J\x1b[3J\x1b[H\x1b[0m' // reset screen+scrollback+cursor before writing the tail
const PREVIEW_THEME = { background: '#0e0f13', foreground: '#e7e8ec', cursor: '#0e0f13' }
interface Card {
el: HTMLElement
term: Terminal
inner: HTMLElement
thumb: HTMLElement
meta: HTMLElement
watch: HTMLElement
status: HTMLElement
}
const cards = new Map<string, Card>()
const cards = new Map<string, PreviewCard>()
let busy = false
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
}
function relTime(ms: number): string {
const s = Math.max(0, (Date.now() - ms) / 1000)
if (s < 60) return `${Math.floor(s)}s`
if (s < 3600) return `${Math.floor(s / 60)}m`
if (s < 86400) return `${Math.floor(s / 3600)}h`
return `${Math.floor(s / 86400)}d`
}
function statusText(s: LiveSession['status']): string {
return s === 'working' ? '⚙ working' : s === 'waiting' ? '⏳ waiting' : s === 'idle' ? '✓ idle' : '·'
}
function name(s: LiveSession): string {
return s.cwd ? (s.cwd.split('/').filter(Boolean).pop() ?? s.cwd) : s.id.slice(0, 8)
}
async function getJSON<T>(url: string): Promise<T | null> {
try {
const res = await fetch(url)
if (!res.ok) return null
return (await res.json()) as T
} catch {
return null
}
}
async function killOne(id: string): Promise<void> {
await fetch(`/live-sessions/${id}`, { method: 'DELETE' }).catch(() => {})
void render()
@@ -100,73 +44,20 @@ async function killBulk(detachedOnly: boolean): Promise<void> {
void render()
}
/** Scale the rendered xterm down so the full screen fits the thumbnail width. */
function fitThumb(card: Card): void {
const w = card.inner.offsetWidth
const h = card.inner.offsetHeight
if (w === 0 || h === 0) return
const scale = Math.min(1, THUMB_W / w)
card.inner.style.transform = `scale(${scale})`
card.thumb.style.height = `${Math.min(h * scale, THUMB_MAX_H)}px`
}
function makeCard(s: LiveSession): Card {
const el0 = el('div', 'mg-card')
const head = el('div', 'mg-card-head')
const title = el('span', 'mg-name', name(s))
const status = el('span', `mg-status mg-${s.status}`, statusText(s.status))
const watch = el('span', s.clientCount > 0 ? 'mg-watch live' : 'mg-watch', `👁 ${s.clientCount}`)
head.append(title, status, watch)
const thumb = el('div', 'mg-thumb')
const inner = el('div', 'mg-thumb-inner')
thumb.append(inner)
// Click the preview to open the session full.
thumb.addEventListener('click', () => {
location.href = `/?join=${s.id}`
})
const term = new Terminal({
cols: Math.max(2, s.cols),
rows: Math.max(2, s.rows),
disableStdin: true,
cursorBlink: false,
fontFamily: 'Menlo, Consolas, monospace',
fontSize: 12,
scrollback: 0,
theme: PREVIEW_THEME,
})
term.open(inner)
const meta = el('div', 'mg-meta')
const actions = el('div', 'mg-actions')
const open = el('a', 'mg-open', 'Open ↗') as HTMLAnchorElement
open.href = `/?join=${s.id}`
function makeCard(s: LiveSessionInfo): PreviewCard {
const kill = el('button', 'mg-kill', 'Kill ✕')
kill.addEventListener('click', () => void killOne(s.id))
actions.append(open, kill)
el0.append(head, thumb, meta, actions)
return { el: el0, term, inner, thumb, meta, watch, status }
return makePreviewCard(s, {
onOpen: (id) => {
location.href = `/?join=${id}`
},
openHref: (id) => `/?join=${id}`,
extraActions: () => [kill],
})
}
async function refreshPreview(id: string, card: Card): Promise<void> {
const p = await getJSON<Preview>(`/live-sessions/${id}/preview`)
if (!p) return
if (card.term.cols !== Math.max(2, p.cols) || card.term.rows !== Math.max(2, p.rows)) {
card.term.resize(Math.max(2, p.cols), Math.max(2, p.rows))
}
card.term.reset()
card.term.write(CLEAR + p.data, () => requestAnimationFrame(() => fitThumb(card)))
}
function updateCard(card: Card, s: LiveSession): void {
card.status.className = `mg-status mg-${s.status}`
card.status.textContent = statusText(s.status)
card.watch.className = s.clientCount > 0 ? 'mg-watch live' : 'mg-watch'
card.watch.textContent = `👁 ${s.clientCount}`
function updateCard(card: PreviewCard, s: LiveSessionInfo): void {
updatePreviewCard(card, s)
card.meta.textContent = `${s.cwd ?? 'unknown dir'} · ${s.cols}×${s.rows} · ${relTime(s.createdAt)} old · ${s.id.slice(0, 8)}`
}
@@ -204,7 +95,7 @@ async function render(): Promise<void> {
busy = true
ensureChrome()
const sessions = (await getJSON<LiveSession[]>('/live-sessions')) ?? []
const sessions = await fetchLiveSessions()
if (countEl) countEl.textContent = `${sessions.length} session(s) running on the host`
const seen = new Set<string>()
@@ -217,7 +108,7 @@ async function render(): Promise<void> {
grid!.append(card.el)
}
updateCard(card, s)
void refreshPreview(s.id, card)
void loadPreviewInto(s.id, card, THUMB_W, THUMB_MAX_H)
}
// Drop cards for sessions that are gone.
for (const [id, card] of cards) {
@@ -240,5 +131,5 @@ void render()
setInterval(() => void render(), REFRESH_MS)
// Re-scale thumbnails if the window resizes.
window.addEventListener('resize', () => {
for (const card of cards.values()) fitThumb(card)
for (const card of cards.values()) fitThumb(card, THUMB_W, THUMB_MAX_H)
})

180
public/preview-grid.ts Normal file
View File

@@ -0,0 +1,180 @@
/**
* public/preview-grid.ts — shared live-preview thumbnail helpers.
*
* The launcher (home session chooser) and the manage page both render a grid of
* the host's running sessions as read-only xterm thumbnails fed by
* GET /live-sessions/:id/preview. This module holds the DRY core: the small DOM
* helper, time/status formatting, the preview card factory, the scaling fit, and
* the preview fetch — so both pages import it instead of duplicating ~80%.
*/
import { Terminal } from '@xterm/xterm'
import type { LiveSessionInfo } from '../src/types.js'
/** Shape of GET /live-sessions/:id/preview. */
export interface SessionPreview {
id: string
cols: number
rows: number
data: string
}
/** Reset screen + scrollback + cursor before writing the tail. */
export const PREVIEW_CLEAR = '\x1b[2J\x1b[3J\x1b[H\x1b[0m'
export const PREVIEW_THEME = { background: '#0e0f13', foreground: '#e7e8ec', cursor: '#0e0f13' }
/** Create an element with an optional class and text content. */
export 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
}
/** Coarse "Ns / Nm / Nh / Nd ago" formatter. */
export function relTime(ms: number): string {
const s = Math.max(0, (Date.now() - ms) / 1000)
if (s < 60) return `${Math.floor(s)}s`
if (s < 3600) return `${Math.floor(s / 60)}m`
if (s < 86400) return `${Math.floor(s / 3600)}h`
return `${Math.floor(s / 86400)}d`
}
/** Human label for a Claude activity status. */
export function statusText(s: LiveSessionInfo['status']): string {
return s === 'working' ? '⚙ working' : s === 'waiting' ? '⏳ waiting' : s === 'idle' ? '✓ idle' : '·'
}
/** Display name for a session: last cwd segment, else the short id. */
export function sessionName(s: LiveSessionInfo): string {
return s.cwd ? (s.cwd.split('/').filter(Boolean).pop() ?? s.cwd) : s.id.slice(0, 8)
}
/** A rendered preview card: the element tree plus the live xterm and its parts. */
export interface PreviewCard {
el: HTMLElement
term: Terminal
inner: HTMLElement
thumb: HTMLElement
watch: HTMLElement
status: HTMLElement
meta: HTMLElement
}
export interface MakeCardOpts {
/** Called when the card (thumbnail or Open action) is activated. */
onOpen: (id: string) => void
/** Optional extra action button(s) appended to the card actions row. */
extraActions?: (s: LiveSessionInfo) => HTMLElement[]
/** Use an <a href> Open link instead of a button (manage page). */
openHref?: (id: string) => string
}
/** Build a preview card for one session (read-only xterm thumbnail). */
export function makePreviewCard(s: LiveSessionInfo, opts: MakeCardOpts): PreviewCard {
const cardEl = el('div', 'mg-card')
const head = el('div', 'mg-card-head')
const title = el('span', 'mg-name', sessionName(s))
const status = el('span', `mg-status mg-${s.status}`, statusText(s.status))
const watch = el('span', s.clientCount > 0 ? 'mg-watch live' : 'mg-watch', `👁 ${s.clientCount}`)
head.append(title, status, watch)
const thumb = el('div', 'mg-thumb')
const inner = el('div', 'mg-thumb-inner')
thumb.append(inner)
thumb.addEventListener('click', () => opts.onOpen(s.id))
const meta = el('div', 'mg-meta')
const actions = el('div', 'mg-actions')
if (opts.openHref) {
const open = el('a', 'mg-open', 'Open ↗') as HTMLAnchorElement
open.href = opts.openHref(s.id)
actions.append(open)
} else {
const open = el('button', 'mg-open', 'Open ↗')
open.addEventListener('click', () => opts.onOpen(s.id))
actions.append(open)
}
if (opts.extraActions) actions.append(...opts.extraActions(s))
const term = new Terminal({
cols: Math.max(2, s.cols),
rows: Math.max(2, s.rows),
disableStdin: true,
cursorBlink: false,
fontFamily: 'Menlo, Consolas, monospace',
fontSize: 12,
scrollback: 0,
theme: PREVIEW_THEME,
})
term.open(inner)
cardEl.append(head, thumb, meta, actions)
return { el: cardEl, term, inner, thumb, watch, status, meta }
}
/** Re-apply a session's live status/watch/meta fields to its card in place. */
export function updatePreviewCard(card: PreviewCard, s: LiveSessionInfo): void {
card.status.className = `mg-status mg-${s.status}`
card.status.textContent = statusText(s.status)
card.watch.className = s.clientCount > 0 ? 'mg-watch live' : 'mg-watch'
card.watch.textContent = `👁 ${s.clientCount}`
}
/** Scale the rendered xterm down so the full screen fits `thumbW` px wide. */
export function fitThumb(card: PreviewCard, thumbW: number, thumbMaxH: number): void {
const w = card.inner.offsetWidth
const h = card.inner.offsetHeight
if (w === 0 || h === 0) return
const scale = Math.min(1, thumbW / w)
card.inner.style.transform = `scale(${scale})`
card.thumb.style.height = `${Math.min(h * scale, thumbMaxH)}px`
}
/** Fetch a session preview, returning null on any failure (best-effort). */
export async function fetchPreview(id: string): Promise<SessionPreview | null> {
try {
const res = await fetch(`/live-sessions/${id}/preview`)
if (!res.ok) return null
return (await res.json()) as SessionPreview
} catch {
return null
}
}
/** Render a preview into a card's xterm, resizing + scaling to fit. */
export function renderPreview(card: PreviewCard, p: SessionPreview, thumbW: number, thumbMaxH: number): void {
if (card.term.cols !== Math.max(2, p.cols) || card.term.rows !== Math.max(2, p.rows)) {
card.term.resize(Math.max(2, p.cols), Math.max(2, p.rows))
}
card.term.reset()
card.term.write(PREVIEW_CLEAR + p.data, () => requestAnimationFrame(() => fitThumb(card, thumbW, thumbMaxH)))
}
/** Fetch + render a session's preview into its card (best-effort, no throw). */
export async function loadPreviewInto(
id: string,
card: PreviewCard,
thumbW: number,
thumbMaxH: number,
): Promise<void> {
const p = await fetchPreview(id)
if (p) renderPreview(card, p, thumbW, thumbMaxH)
}
/** Fetch the host's live sessions list, returning [] on failure. */
export async function fetchLiveSessions(): Promise<LiveSessionInfo[]> {
try {
const res = await fetch('/live-sessions')
const data: unknown = await res.json()
return Array.isArray(data) ? (data as LiveSessionInfo[]) : []
} catch {
return []
}
}

View File

@@ -146,14 +146,11 @@ export class TabApp {
cwd?: string,
initialInput?: string,
): TabEntry {
const entry: TabEntry = {
session: null as unknown as TerminalSession,
customTitle,
autoTitle: null,
hasActivity: false,
el: null,
}
entry.session = new TerminalSession({
// Build the session FIRST so `entry` is never typed with a null session.
// The callbacks below capture `entry` by reference; they only fire after
// construction (async), by which point `entry` is assigned.
let entry: TabEntry
const session = new TerminalSession({
sessionId,
...(cwd !== undefined ? { cwd } : {}),
...(initialInput !== undefined ? { initialInput } : {}),
@@ -177,10 +174,11 @@ export class TabApp {
}
},
})
this.paneHost.appendChild(entry.session.el)
entry = { session, customTitle, autoTitle: null, hasActivity: false, el: null }
this.paneHost.appendChild(session.el)
this.tabs.push(entry)
entry.session.applyTheme(THEMES[this.settings.theme] ?? THEMES['dark']!, this.settings.fontSize)
entry.session.connect()
session.applyTheme(THEMES[this.settings.theme] ?? THEMES['dark']!, this.settings.fontSize)
session.connect()
return entry
}

View File

@@ -16,6 +16,10 @@ import { WebLinksAddon } from '@xterm/addon-web-links'
import type { ClaudeStatus, ClientMessage, ServerMessage } from '../src/types.js'
import { folderFromTitle, cwdFromOsc7 } from './title-util.js'
// Delay after the shell is ready before typing a session's initial command
// (e.g. `claude --resume …`), giving the shell time to finish its prompt.
const INITIAL_INPUT_DELAY_MS = 700
const RESET = '\x1b[0m'
const BOLD = '\x1b[1m'
const DIM = '\x1b[2m'
@@ -84,6 +88,7 @@ export class TerminalSession {
private reconnectDelay = 1000 // ms; doubles each attempt, capped at 30 000
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private resizeTimer: ReturnType<typeof setTimeout> | null = null
private initialInputTimer: ReturnType<typeof setTimeout> | null = null
private exitListener: { dispose(): void } | null = null
private isConnecting = false
private disposed = false
@@ -232,7 +237,8 @@ export class TerminalSession {
})
socket.addEventListener('error', () => {
this.isConnecting = false
// Do NOT reset isConnecting here — the 'close' event always follows 'error'
// and owns the cleanup (resetting it twice could race a reconnect).
})
}
@@ -247,7 +253,11 @@ export class TerminalSession {
if (this.initialInput !== undefined && !this.initialSent) {
this.initialSent = true
const cmd = this.initialInput
setTimeout(() => this.send(cmd), 700)
this.initialInputTimer = setTimeout(() => {
this.initialInputTimer = null
if (this.disposed) return
this.send(cmd)
}, INITIAL_INPUT_DELAY_MS)
}
break
}
@@ -364,12 +374,9 @@ export class TerminalSession {
hide(): void {
this.el.style.display = 'none'
// v0.4: withdraw our size vote so a backgrounded mirror doesn't clamp the
// shared PTY to this device's size. Output still streams (background mirror).
if (this.ws !== null && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(buildMessage({ type: 'blur' }))
}
// Force show() to re-cast our dims even if unchanged.
// v0.4 (latest-writer-wins): hiding does NOT resize the shared PTY — the
// device still actively viewing keeps its size. Output still streams
// (background mirror). Force show()/refit() to re-cast our dims on return.
this.lastCols = -1
this.lastRows = -1
}
@@ -385,6 +392,10 @@ export class TerminalSession {
clearTimeout(this.resizeTimer)
this.resizeTimer = null
}
if (this.initialInputTimer !== null) {
clearTimeout(this.initialInputTimer)
this.initialInputTimer = null
}
this.resizeObserver.disconnect()
if (this.ws !== null) {
try {

View File

@@ -26,10 +26,16 @@ const DEFAULT_IDLE_TTL_SEC = 24 * 60 * 60 // 24 hours in seconds
const DEFAULT_SCROLLBACK_BYTES = 2 * 1024 * 1024 // 2 MB
const DEFAULT_MAX_PAYLOAD_BYTES = 1 * 1024 * 1024 // 1 MB
const DEFAULT_WS_PATH = '/term'
const DEFAULT_MAX_SESSIONS = 50
const DEFAULT_MAX_MSGS_PER_SEC = 2000
const DEFAULT_PERM_TIMEOUT_MS = 5 * 60_000 // H3: hold a PermissionRequest for 5 min
const DEFAULT_REAP_INTERVAL_MS = 60_000 // idle reaper sweeps every minute
const DEFAULT_PREVIEW_BYTES = 24 * 1024 // manage-page preview: tail of scrollback
// ── helpers ───────────────────────────────────────────────────────────────────
function parsePositiveInt(
/** Parse a non-negative integer env value (0 allowed), or the fallback when unset. */
function parseNonNegativeInt(
raw: string | undefined,
label: string,
fallback: number,
@@ -77,6 +83,16 @@ function parseIdleTtl(raw: string | undefined): number {
* - Deduplicate.
* - Never include 0.0.0.0 (wildcard listen address, never a browser Origin).
*/
/** True iff `value` parses as a URL with an http: or https: scheme (M1). */
function isHttpOrigin(value: string): boolean {
try {
const u = new URL(value)
return u.protocol === 'http:' || u.protocol === 'https:'
} catch {
return false
}
}
function deriveAllowedOrigins(port: number, extraEnv: string | undefined): readonly string[] {
const set = new Set<string>()
@@ -104,11 +120,14 @@ function deriveAllowedOrigins(port: number, extraEnv: string | undefined): reado
}
}
// 3. Merge ALLOWED_ORIGINS env var (comma-separated list of origin strings)
// 3. Merge ALLOWED_ORIGINS env var (comma-separated list of origin strings).
// Reject anything that is not a well-formed http(s) origin so an exotic
// scheme (file:, data:, javascript:) can't slip into the whitelist (M1).
if (extraEnv) {
for (const raw of extraEnv.split(',')) {
const trimmed = raw.trim()
if (trimmed) set.add(trimmed)
if (trimmed === '') continue
if (isHttpOrigin(trimmed)) set.add(trimmed)
}
}
@@ -136,13 +155,13 @@ export function loadConfig(env: EnvLike): Config {
const idleTtlMs = parseIdleTtl(env['IDLE_TTL'])
const scrollbackBytes = parsePositiveInt(
const scrollbackBytes = parseNonNegativeInt(
env['SCROLLBACK_BYTES'],
'SCROLLBACK_BYTES',
DEFAULT_SCROLLBACK_BYTES,
)
const maxPayloadBytes = parsePositiveInt(
const maxPayloadBytes = parseNonNegativeInt(
env['MAX_PAYLOAD_BYTES'],
'MAX_PAYLOAD_BYTES',
DEFAULT_MAX_PAYLOAD_BYTES,
@@ -150,6 +169,28 @@ export function loadConfig(env: EnvLike): Config {
const wsPath = env['WS_PATH'] ?? DEFAULT_WS_PATH
const maxSessions = parseNonNegativeInt(env['MAX_SESSIONS'], 'MAX_SESSIONS', DEFAULT_MAX_SESSIONS)
const maxMsgsPerSec = parseNonNegativeInt(
env['MAX_MSGS_PER_SEC'],
'MAX_MSGS_PER_SEC',
DEFAULT_MAX_MSGS_PER_SEC,
)
const permTimeoutMs = parseNonNegativeInt(
env['PERM_TIMEOUT_MS'],
'PERM_TIMEOUT_MS',
DEFAULT_PERM_TIMEOUT_MS,
)
const reapIntervalMs = parseNonNegativeInt(
env['REAP_INTERVAL_MS'],
'REAP_INTERVAL_MS',
DEFAULT_REAP_INTERVAL_MS,
)
const previewBytes = parseNonNegativeInt(env['PREVIEW_BYTES'], 'PREVIEW_BYTES', DEFAULT_PREVIEW_BYTES)
const useTmux = resolveUseTmux(env['USE_TMUX'])
const allowedOrigins = deriveAllowedOrigins(port, env['ALLOWED_ORIGINS'])
@@ -163,6 +204,11 @@ export function loadConfig(env: EnvLike): Config {
scrollbackBytes,
maxPayloadBytes,
wsPath,
maxSessions,
maxMsgsPerSec,
permTimeoutMs,
reapIntervalMs,
previewBytes,
useTmux,
allowedOrigins,
} satisfies Config)

View File

@@ -6,7 +6,7 @@
* can `claude --resume <id>` in the right place). Read-only; best-effort.
*/
import fs from 'node:fs';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
@@ -61,27 +61,27 @@ export function parseSessionMeta(jsonlText: string): { cwd: string | null; previ
return { cwd, preview: preview.replace(/\s+/g, ' ').trim().slice(0, 120) };
}
function readHead(file: string, max: number): string {
try {
const fd = fs.openSync(file, 'r');
/** Read up to `max` bytes from the head of a file (async, best-effort). */
async function readHead(file: string, max: number): Promise<string> {
let fh: fs.FileHandle | undefined;
try {
fh = await fs.open(file, 'r');
const buf = Buffer.alloc(max);
const n = fs.readSync(fd, buf, 0, max, 0);
return buf.subarray(0, n).toString('utf8');
} finally {
fs.closeSync(fd);
}
const { bytesRead } = await fh.read(buf, 0, max, 0);
return buf.subarray(0, bytesRead).toString('utf8');
} catch {
return '';
} finally {
await fh?.close().catch(() => undefined);
}
}
/** Most-recently-modified Claude Code sessions across all projects. */
export function listSessions(limit = 50): HistorySession[] {
/** Most-recently-modified Claude Code sessions across all projects (async). */
export async function listSessions(limit = 50): Promise<HistorySession[]> {
const root = path.join(os.homedir(), '.claude', 'projects');
let dirs: string[];
try {
dirs = fs.readdirSync(root);
dirs = await fs.readdir(root);
} catch {
return [];
}
@@ -91,14 +91,14 @@ export function listSessions(limit = 50): HistorySession[] {
const dirPath = path.join(root, dir);
let names: string[];
try {
names = fs.readdirSync(dirPath);
names = await fs.readdir(dirPath);
} catch {
continue;
}
for (const name of names) {
if (!name.endsWith('.jsonl')) continue;
try {
const st = fs.statSync(path.join(dirPath, name));
const st = await fs.stat(path.join(dirPath, name));
files.push({
id: name.slice(0, -'.jsonl'.length),
file: path.join(dirPath, name),
@@ -112,10 +112,13 @@ export function listSessions(limit = 50): HistorySession[] {
files.sort((a, b) => b.mtimeMs - a.mtimeMs);
return files.slice(0, limit).map((f) => {
const meta = parseSessionMeta(readHead(f.file, 256 * 1024));
const top = files.slice(0, limit);
return Promise.all(
top.map(async (f) => {
const meta = parseSessionMeta(await readHead(f.file, 256 * 1024));
const cwd = meta.cwd ?? '';
const project = cwd !== '' ? (cwd.split('/').filter(Boolean).pop() ?? cwd) : 'unknown';
return { id: f.id, cwd, project, mtimeMs: f.mtimeMs, preview: meta.preview };
});
}),
);
}

View File

@@ -24,7 +24,7 @@ export const SESSION_ID_RE =
// ─── Type whitelist ───────────────────────────────────────────────────────────
const ALLOWED_TYPES = new Set<string>(['attach', 'input', 'resize', 'blur', 'approve', 'reject'])
const ALLOWED_TYPES = new Set<string>(['attach', 'input', 'resize', 'approve', 'reject'])
// ─── parseClientMessage ───────────────────────────────────────────────────────
@@ -73,11 +73,6 @@ export function parseClientMessage(raw: string): ParseResult {
return validateInput(obj)
}
// blur (v0.4) carries no payload — withdraw this client's size vote.
if (type === 'blur') {
return { ok: true, message: { type: 'blur' } }
}
// approve/reject (H3) carry no payload.
if (type === 'approve') {
return { ok: true, message: { type: 'approve' } }
@@ -139,6 +134,11 @@ function validateAttach(obj: Record<string, unknown>): ParseResult {
}
// Optional cwd (M6): must be an absolute path string if present.
// NOTE (Sec L2): the only check here is a leading '/'. Any further path
// normalisation (e.g. path.resolve) stays on the backend (session.ts), because
// this module is shared with the browser bundle and must NOT import node:path.
// Within the current threat model this is informational — the caller already
// has a full shell, so a crafted cwd grants nothing beyond what they can type.
const rawCwd = obj['cwd']
let cwd: string | undefined
if (rawCwd !== undefined) {

View File

@@ -23,6 +23,7 @@
*/
import { createServer } from 'node:http'
import net from 'node:net'
import { URL } from 'node:url'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -39,7 +40,7 @@ import { isOriginAllowed } from './http/origin.js'
import { parseHookEvent } from './http/hook.js'
import { listSessions } from './http/history.js'
import { createSessionManager } from './session/manager.js'
import { detachWs, writeInput, setClientDims, clearClientDims } from './session/session.js'
import { detachWs, writeInput, setClientDims } from './session/session.js'
import type { Config } from './types.js'
import { WS_OPEN } from './types.js'
@@ -48,22 +49,38 @@ import { WS_OPEN } from './types.js'
const DEFAULT_COLS = 80
const DEFAULT_ROWS = 24
// How many bytes of recent scrollback the manage-page preview renders (enough to
// contain a full-screen TUI repaint, so the thumbnail shows the current screen).
const PREVIEW_BYTES = 24 * 1024
// Width of the leaky-bucket window for per-connection WS rate limiting (10).
const RATE_WINDOW_MS = 1000
// H3: how long the server holds a PermissionRequest before falling back to
// Claude's own interactive prompt (so it never hangs if nobody responds).
const PERM_TIMEOUT_MS = 5 * 60_000
// 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 } } }
}
/** True for loopback peers (hooks always run on the host). */
/**
* True for loopback peers (hooks always run on the host). Accepts the whole
* 127.0.0.0/8 range and IPv4-mapped IPv6 forms, not just 127.0.0.1, so a peer
* address behind a local proxy / alternate loopback alias still passes.
*/
function isLoopback(ip: string): boolean {
return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'
if (ip === '::1') return true
// Strip an IPv4-mapped IPv6 prefix (::ffff:127.0.0.1) down to the IPv4 part.
const v4 = ip.startsWith('::ffff:') ? ip.slice('::ffff:'.length) : ip
if (net.isIPv4(v4)) {
return v4.startsWith('127.')
}
return false
}
/** Strip control chars and truncate a user-controlled value before logging (M2). */
function sanitizeForLog(value: unknown): string {
return String(value)
.slice(0, LOG_FIELD_MAX)
// eslint-disable-next-line no-control-regex
.replace(/[\x00-\x1f]/g, '?')
}
// ── helpers ───────────────────────────────────────────────────────────────────
@@ -105,14 +122,34 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
// ── Express static hosting ────────────────────────────────────────────────
const app = express()
// ── Security headers (Sec H2) ─────────────────────────────────────────────
// Hand-rolled (no helmet) to keep deps minimal. CSP is conservative; the
// 'unsafe-inline' on style-src is required because xterm.js injects inline
// styles for the terminal viewport (verified: dropping it breaks rendering).
app.use((_req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff')
res.setHeader('X-Frame-Options', 'DENY')
res.setHeader('Referrer-Policy', 'no-referrer')
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:",
)
next()
})
// Serve the entire public/ directory (including public/build/ esbuild output).
// Note: `npm run build:web` must be run before `npm start` to populate public/build/.
const publicDir = path.join(__dirname, '..', 'public')
app.use(express.static(publicDir))
// ── Claude Code history (O2) — list past sessions for the resume browser ──
app.get('/sessions', (_req, res) => {
res.json(listSessions(50))
// SECURITY (Sec H3, accepted risk): this returns Claude session cwds + the
// first ~120 chars of each first prompt + resumable UUIDs, UNAUTHENTICATED, to
// any LAN device. That is consistent with the app's threat model — this whole
// app hands a full shell to anyone who can reach the port (no auth, LAN-only,
// never public-internet). Deploy behind Tailscale. See TECH_DOC §7.
app.get('/sessions', async (_req, res) => {
res.json(await listSessions(50))
})
// ── Live sessions (v0.4) — running server sessions, for multi-device discovery.
@@ -133,12 +170,26 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
id: session.meta.id,
cols: session.pty.cols,
rows: session.pty.rows,
data: session.buffer.tail(PREVIEW_BYTES),
data: session.buffer.tail(cfg.previewBytes),
})
})
// CSRF guard for the state-changing DELETE routes (Arch 5b / Sec H2): the WS
// upgrade checks Origin, but plain HTTP routes don't — without this, a foreign
// page could fire a no-preflight DELETE and Kill-All sessions. Reuse the same
// Origin whitelist; reject missing/foreign Origin with 403.
function requireAllowedOrigin(req: IncomingMessage, res: Response): boolean {
const origin = req.headers['origin'] as string | undefined
if (!isOriginAllowed(origin, cfg.allowedOrigins)) {
res.status(403).end()
return false
}
return true
}
// Kill ALL sessions, or only detached ones (?detached=1) — manage page bulk action.
app.delete('/live-sessions', (req, res) => {
if (!requireAllowedOrigin(req, res)) return
const onlyDetached = req.query['detached'] === '1'
let killed = 0
for (const s of manager.list()) {
@@ -150,6 +201,7 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
// Kill one session by id — manage page per-row action.
app.delete('/live-sessions/:id', (req, res) => {
if (!requireAllowedOrigin(req, res)) return
const ok = manager.killById(req.params.id)
res.status(ok ? 204 : 404).end()
})
@@ -194,7 +246,7 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
pendingApprovals.delete(sessionId)
res.json({}) // timeout → fall back to Claude's interactive prompt
manager.handleHookEvent(sessionId, 'idle')
}, PERM_TIMEOUT_MS)
}, cfg.permTimeoutMs)
pendingApprovals.set(sessionId, { res, timer })
// Show the approve/reject affordance on the client.
@@ -213,10 +265,9 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
})
// ── Idle reaper ───────────────────────────────────────────────────────────
const REAP_INTERVAL_MS = 60_000 // check every minute
const reapTimer = setInterval(() => {
manager.reapIdle(Date.now())
}, REAP_INTERVAL_MS)
}, cfg.reapIntervalMs)
// Don't let this timer prevent the process from exiting.
reapTimer.unref()
@@ -250,14 +301,43 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
// State: session id bound after the first 'attach' frame.
let boundSessionId: string | null = null
// Per-connection leaky-bucket rate limit (Sec M3). maxPayload caps frame
// SIZE; this caps frame FREQUENCY so an input/resize flood can't saturate
// CPU/IO. Over-limit frames are dropped (NOT a close — avoid killing a
// legitimate burst); the warn log is throttled to once per window.
let windowStart = Date.now()
let msgsThisWindow = 0
let droppedThisWindow = 0
let warnedThisWindow = false
// Per-connection message handler.
const onMessage = (raw: Buffer | string): void => {
// ── Rate limit (leaky bucket over a 1s window) ────────────────────────
const now = Date.now()
if (now - windowStart >= RATE_WINDOW_MS) {
windowStart = now
msgsThisWindow = 0
droppedThisWindow = 0
warnedThisWindow = false
}
msgsThisWindow += 1
if (msgsThisWindow > cfg.maxMsgsPerSec) {
droppedThisWindow += 1
if (!warnedThisWindow) {
warnedThisWindow = true
console.error(
`[server] rate limit: dropping frames over ${cfg.maxMsgsPerSec}/s on one connection`,
)
}
return
}
const text = typeof raw === 'string' ? raw : raw.toString('utf8')
const result = parseClientMessage(text)
if (!result.ok) {
// Discard invalid frames; log without noisy stack traces (avoid noise per spec).
console.error('[server] invalid client message discarded:', result.error)
console.error('[server] invalid client message discarded:', sanitizeForLog(result.error))
return
}
@@ -305,11 +385,8 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
if (msg.type === 'input') {
writeInput(session, msg.data)
} else if (msg.type === 'resize') {
// Per-client dims; the PTY tracks the min across actively-viewing devices.
// Latest-writer-wins: this device's dims drive the shared PTY size.
setClientDims(session, ws, msg.cols, msg.rows)
} else if (msg.type === 'blur') {
// Tab hidden on this device — withdraw its size vote (still mirrors output).
clearClientDims(session, ws)
} else if (msg.type === 'approve') {
// H3: resolve the held PermissionRequest with allow.
resolvePending(boundSessionId, permDecision('allow'))
@@ -329,15 +406,23 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
ws.on('close', () => {
if (boundSessionId === null) return
// H3: release any held approval so the hook isn't stuck for the full timeout.
resolvePending(boundSessionId, {})
const session = manager.get(boundSessionId)
if (session === undefined) return
if (session === undefined) {
// Session already gone — make sure no held approval dangles.
resolvePending(boundSessionId, {})
return
}
// Invariant #2: never kill the PTY on WS close — detach THIS client only
// (the PTY stays alive for other devices and for reconnect).
detachWs(session, ws, Date.now())
// H3: only release a held approval when the LAST viewer leaves. With
// multi-device sharing (v0.4), another device may still be looking at the
// same pending approval — closing one mirror must not cancel it for them.
if (session.clients.size === 0) {
resolvePending(boundSessionId, {})
}
})
// ── WS error ─────────────────────────────────────────────────────────────
@@ -361,16 +446,25 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
httpServer.closeIdleConnections() // drop idle keep-alive (hook) sockets so close() drains
}
process.on('SIGINT', onSignal)
process.on('SIGTERM', onSignal)
// ── uncaughtException: log and exit for truly unexpected errors ───────────
// (spawn failures / send failures are handled above and must NOT reach here)
process.on('uncaughtException', (err: Error) => {
const onUncaught = (err: Error): void => {
console.error('[server] uncaughtException — exiting:', err)
doShutdown()
process.exit(1)
})
}
process.on('SIGINT', onSignal)
process.on('SIGTERM', onSignal)
process.on('uncaughtException', onUncaught)
// Remove the process-level listeners this server registered, so repeated
// startServer()/close() cycles (tests, library use) don't leak handlers.
function removeProcessListeners(): void {
process.off('SIGINT', onSignal)
process.off('SIGTERM', onSignal)
process.off('uncaughtException', onUncaught)
}
// ── Start listening ───────────────────────────────────────────────────────
httpServer.listen(cfg.port, cfg.bindHost, () => {
@@ -384,6 +478,7 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
return {
close(): Promise<void> {
return new Promise<void>((resolve) => {
removeProcessListeners()
doShutdown()
httpServer.close(() => resolve())
// Drop idle keep-alive sockets (e.g. an undici hook connection) so the

View File

@@ -18,7 +18,8 @@
* - #2: WS close → detach ONE client, never kill PTY (reap only when the last leaves).
* - #5 (relaxed v0.4): a session may have MANY concurrent clients (multi-device
* mirror sharing); a new attach JOINS instead of kicking. Output/exit/status
* broadcast to all; the PTY size is the min across clients (tmux-style).
* broadcast to all; the PTY size is latest-writer-wins (the most recently
* focused/fitted device drives the shared size).
* - #6: every ws.send is guarded by readyState === WS_OPEN (handled inside session.ts).
*
* Coding style: immutable-update preference, no console.log, errors explicit.
@@ -80,6 +81,17 @@ export function createSessionManager(cfg: Config): SessionManager {
// Otherwise: detached-then-exit (L1) — leave in table for reconnect replay.
}
/**
* DoS guard: refuse to spawn a brand-new session once the table is at the
* configured cap. Throwing here propagates to server.ts (M4 path), which
* sends exit(-1, reason) and closes the offending connection.
*/
function assertUnderSessionCap(): void {
if (sessions.size >= cfg.maxSessions) {
throw new Error(`session limit reached (max ${cfg.maxSessions})`);
}
}
function handleAttach(
ws: WebSocketLike,
sessionId: string | null,
@@ -89,6 +101,9 @@ export function createSessionManager(cfg: Config): SessionManager {
): Session {
// ── Case 1: null → always create a new session ──────────────────────────
if (sessionId === null) {
// DoS guard: cap concurrent sessions. Throwing reuses the M4 path —
// server.ts catches it and sends exit(-1, reason) + closes this connection.
assertUnderSessionCap();
// M4: createSession may throw (spawn failure). Do NOT catch here.
// M6: cwd (if given) is the spawn directory for "new tab here".
const session = createSession(cfg, dims, now, onSessionExit, undefined, cwd);
@@ -128,6 +143,8 @@ export function createSessionManager(cfg: Config): SessionManager {
}
// ── Case 4: session not found → create a new one ─────────────────────────
// DoS guard (see Case 1): cap concurrent sessions before spawning.
assertUnderSessionCap();
// M4: createSession may throw. Do NOT catch here.
const session = createSession(cfg, dims, now, onSessionExit);
attachWs(session, ws);

View File

@@ -108,7 +108,6 @@ export function createSession(
meta,
buffer: createRingBuffer(cfg.scrollbackBytes),
clients: new Set(),
clientDims: new Map(),
detachedAt: null,
lastOutputAt: now,
exitedAt: null,
@@ -161,7 +160,6 @@ export function attachWs(session: Session, ws: WebSocketLike): void {
*/
export function detachWs(session: Session, ws: WebSocketLike, now: number): void {
session.clients.delete(ws);
session.clientDims.delete(ws);
// A client leaving does NOT resize the PTY — whatever device is still actively
// viewing keeps its size. Start the idle clock only when the last client goes.
if (session.clients.size === 0) {
@@ -175,36 +173,26 @@ export function writeInput(session: Session, data: string): void {
session.pty.write(data);
}
/**
* Record one client's requested dims and resize the PTY to the new min across
* all clients (tmux-style). No-op after exit (L4); idempotent when unchanged.
*/
/**
* Latest-writer-wins: the device that most recently fit/focused drives the PTY
* size, so whichever device you are actively using is full-screen. (A shared
* PTY can only be one size; min-sizing letterboxed the bigger screen.) The
* frontend re-sends dims when a pane is shown or its window regains focus, so
* switching devices reclaims full size. No-op after exit (L4) / when unchanged.
*
* The `ws` argument identifies the requesting client; the size is applied
* directly to the PTY (no per-client dims map — latest writer wins).
*/
export function setClientDims(
session: Session,
ws: WebSocketLike,
_ws: WebSocketLike,
cols: number,
rows: number,
): void {
if (session.exitedAt !== null) return;
session.clientDims.set(ws, { cols, rows });
applyDims(session, cols, rows);
}
/**
* Forget a client's size when its tab goes hidden — but do NOT resize: the
* device still actively viewing keeps its size (no letterboxing of a mirror).
*/
export function clearClientDims(session: Session, ws: WebSocketLike): void {
session.clientDims.delete(ws);
}
/**
* Kill the session (idle reclaim). Under tmux this ends the actual shell via
* `tmux kill-session` (H1); killing only the client pty would leave the tmux

View File

@@ -27,6 +27,11 @@ export interface Config {
readonly scrollbackBytes: number; // ring buffer capacity, default 2MB
readonly maxPayloadBytes: number; // max WS frame bytes, default 1MB (L5)
readonly wsPath: string; // WS upgrade path, default '/term' (L3; invariant 8)
readonly maxSessions: number; // cap on concurrent PTY sessions (DoS guard), default 50
readonly maxMsgsPerSec: number; // per-connection WS message rate cap (DoS guard), default 2000
readonly permTimeoutMs: number; // H3: how long a held PermissionRequest waits before fallback
readonly reapIntervalMs: number; // idle-reaper sweep interval
readonly previewBytes: number; // manage-page preview: bytes of scrollback tail rendered
readonly useTmux: boolean; // H1: spawn the shell inside tmux so it survives a server restart
readonly allowedOrigins: readonly string[]; // derived from NIC IPs, NOT bindHost (M1)
}
@@ -45,9 +50,6 @@ export type ClientMessage =
| { type: 'attach'; sessionId: string | null; cwd?: string }
| { type: 'input'; data: string }
| { type: 'resize'; cols: number; rows: number }
// v0.4: this client stopped actively viewing (tab hidden) — withdraw its size
// vote so a background mirror doesn't clamp the shared PTY (min-dims).
| { type: 'blur' }
| { type: 'approve' }
| { type: 'reject' };
@@ -150,9 +152,6 @@ export interface Session {
* detached but PTY still alive (vibe-coding core). Output/exit/status are
* broadcast to every client; any client can send input (shared control). */
readonly clients: Set<WebSocketLike>;
/** Per-client requested terminal dims; the PTY uses the MIN cols/rows across
* all clients (tmux-style) so every device sees content without overflow. */
readonly clientDims: Map<WebSocketLike, Dims>;
/** Time the LAST client left (clients became empty); null while ≥1 attached. */
detachedAt: number | null;
/** last pty.onData timestamp; reapIdle liveness proxy (M3). */
@@ -176,8 +175,7 @@ export interface Session {
// attachWs(session: Session, ws: WebSocketLike): void // adds a client, replays buffer (no size vote until it resizes)
// detachWs(session: Session, ws: WebSocketLike, now: number): void // removes one client; never kills PTY
// writeInput(session: Session, data: string): void // no-op after exit (L4)
// setClientDims(session: Session, ws: WebSocketLike, cols, rows): void // PTY = min over active viewers (L4 no-op after exit)
// clearClientDims(session: Session, ws: WebSocketLike): void // withdraw this client's size vote (tab hidden / blur)
// setClientDims(session: Session, ws: WebSocketLike, cols, rows): void // PTY = latest-writer-wins (L4 no-op after exit)
// kill(session: Session): void
/* ──────────────────────── manager (§3.5) ─────────────────────── */

View File

@@ -35,6 +35,16 @@ vi.mock('node:os', async (importOriginal) => {
}
})
// ── mock tmux availability so resolveUseTmux's auto-detect is deterministic ──
const mockTmuxAvailable = vi.fn<[], boolean>()
mockTmuxAvailable.mockReturnValue(false)
vi.mock('../src/session/tmux.js', () => ({
tmuxAvailable: () => mockTmuxAvailable(),
tmuxName: (id: string) => `web_${id}`,
hasSession: () => false,
killSession: () => undefined,
}))
// Import config AFTER mock is set up (dynamic import ensures mock applies)
const { loadConfig } = await import('../src/config.js')
@@ -270,6 +280,93 @@ describe('loadConfig — allowedOrigins (M1)', () => {
})
})
// ── MAX_SESSIONS + new operational constants ──────────────────────────────────
describe('loadConfig — session/rate/timer constants', () => {
beforeEach(() => {
mockNetworkInterfaces.mockReturnValue({})
mockHomedir.mockReturnValue('/home/testuser')
})
it('defaults maxSessions to 50', () => {
expect(loadConfig({}).maxSessions).toBe(50)
})
it('reads MAX_SESSIONS from env', () => {
expect(loadConfig({ MAX_SESSIONS: '20' }).maxSessions).toBe(20)
})
it('throws for a non-integer MAX_SESSIONS', () => {
expect(() => loadConfig({ MAX_SESSIONS: 'lots' })).toThrow()
})
it('defaults maxMsgsPerSec to 2000 and reads MAX_MSGS_PER_SEC', () => {
expect(loadConfig({}).maxMsgsPerSec).toBe(2000)
expect(loadConfig({ MAX_MSGS_PER_SEC: '500' }).maxMsgsPerSec).toBe(500)
})
it('defaults permTimeoutMs / reapIntervalMs / previewBytes and reads overrides', () => {
const def = loadConfig({})
expect(def.permTimeoutMs).toBe(5 * 60_000)
expect(def.reapIntervalMs).toBe(60_000)
expect(def.previewBytes).toBe(24 * 1024)
const over = loadConfig({ PERM_TIMEOUT_MS: '1000', REAP_INTERVAL_MS: '2000', PREVIEW_BYTES: '4096' })
expect(over.permTimeoutMs).toBe(1000)
expect(over.reapIntervalMs).toBe(2000)
expect(over.previewBytes).toBe(4096)
})
})
// ── resolveUseTmux branches (USE_TMUX env) ────────────────────────────────────
describe('loadConfig — resolveUseTmux', () => {
beforeEach(() => {
mockNetworkInterfaces.mockReturnValue({})
mockHomedir.mockReturnValue('/home/testuser')
mockTmuxAvailable.mockReturnValue(false)
})
it.each(['1', 'true', 'on', 'ON', ' True '])('forces tmux ON for %j', (v) => {
expect(loadConfig({ USE_TMUX: v }).useTmux).toBe(true)
})
it.each(['0', 'false', 'off'])('forces tmux OFF for %j', (v) => {
expect(loadConfig({ USE_TMUX: v }).useTmux).toBe(false)
})
it('auto-detects (unset) → true when tmux is available', () => {
mockTmuxAvailable.mockReturnValue(true)
expect(loadConfig({}).useTmux).toBe(true)
})
it('auto-detects (unset / "auto") → false when tmux is unavailable', () => {
mockTmuxAvailable.mockReturnValue(false)
expect(loadConfig({}).useTmux).toBe(false)
expect(loadConfig({ USE_TMUX: 'auto' }).useTmux).toBe(false)
})
})
// ── ALLOWED_ORIGINS scheme validation (M1) ────────────────────────────────────
describe('loadConfig — ALLOWED_ORIGINS scheme validation', () => {
beforeEach(() => {
mockNetworkInterfaces.mockReturnValue({})
mockHomedir.mockReturnValue('/home/testuser')
})
it('rejects non-http(s) scheme entries (file:, javascript:, bare host)', () => {
const cfg = loadConfig({
ALLOWED_ORIGINS: 'file:///etc/passwd,javascript:alert(1),notaurl,http://ok.local:3000',
})
expect(cfg.allowedOrigins).toContain('http://ok.local:3000')
expect(cfg.allowedOrigins.some((o) => o.startsWith('file:'))).toBe(false)
expect(cfg.allowedOrigins.some((o) => o.startsWith('javascript:'))).toBe(false)
expect(cfg.allowedOrigins).not.toContain('notaurl')
})
it('keeps valid https entries', () => {
const cfg = loadConfig({ ALLOWED_ORIGINS: 'https://phone.local:3000' })
expect(cfg.allowedOrigins).toContain('https://phone.local:3000')
})
})
// ── Immutability ──────────────────────────────────────────────────────────────
describe('loadConfig — returned object is frozen', () => {
beforeEach(() => {

View File

@@ -1,6 +1,28 @@
import { describe, it, expect } from 'vitest'
import { parseSessionMeta } from '../src/http/history.js'
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ── mock node:fs/promises + node:os ──────────────────────────────────────────
// Hoisted by vitest; history.ts binds to these mocks.
const mockReaddir = vi.fn()
const mockStat = vi.fn()
const mockOpen = vi.fn()
const mockHomedir = vi.fn(() => '/home/tester')
vi.mock('node:fs/promises', () => ({
default: {
readdir: (...a: unknown[]) => mockReaddir(...a),
stat: (...a: unknown[]) => mockStat(...a),
open: (...a: unknown[]) => mockOpen(...a),
},
}))
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>()
return { ...actual, default: { ...actual.default, homedir: () => mockHomedir() } }
})
const { parseSessionMeta, listSessions } = await import('../src/http/history.js')
// ── parseSessionMeta (pure) ───────────────────────────────────────────────────
describe('parseSessionMeta', () => {
it('extracts cwd and the first user prompt (string content)', () => {
const jsonl = [
@@ -31,3 +53,71 @@ describe('parseSessionMeta', () => {
expect(parseSessionMeta('not json\n{"type":"summary"}')).toEqual({ cwd: null, preview: '' })
})
})
// ── listSessions (fs traversal, async) ────────────────────────────────────────
describe('listSessions', () => {
function mockFile(text: string) {
const buf = Buffer.from(text, 'utf8')
return {
read: vi.fn(async (b: Buffer, off: number, len: number) => {
const n = buf.copy(b, off, 0, Math.min(len, buf.length))
return { bytesRead: n }
}),
close: vi.fn(async () => undefined),
}
}
beforeEach(() => {
mockReaddir.mockReset()
mockStat.mockReset()
mockOpen.mockReset()
mockHomedir.mockReturnValue('/home/tester')
})
it('returns [] when the projects root is missing', async () => {
mockReaddir.mockRejectedValueOnce(new Error('ENOENT'))
expect(await listSessions()).toEqual([])
})
it('lists .jsonl sessions newest-first with parsed cwd/preview/project', async () => {
mockReaddir
.mockResolvedValueOnce(['projA']) // root
.mockResolvedValueOnce(['old.jsonl', 'new.jsonl', 'ignore.txt']) // projA
mockStat.mockImplementation(async (p: string) => ({
mtimeMs: p.endsWith('new.jsonl') ? 2000 : 1000,
}))
mockOpen.mockImplementation(async (p: string) =>
p.endsWith('new.jsonl')
? mockFile(JSON.stringify({ type: 'user', cwd: '/work/newdir', message: { role: 'user', content: 'newest task' } }))
: mockFile(JSON.stringify({ type: 'user', cwd: '/work/olddir', message: { role: 'user', content: 'older task' } })),
)
const out = await listSessions()
expect(out.map((s) => s.id)).toEqual(['new', 'old']) // newest first
expect(out[0]).toMatchObject({ id: 'new', cwd: '/work/newdir', project: 'newdir', preview: 'newest task' })
// .txt is skipped
expect(out).toHaveLength(2)
})
it('skips a project subdir that cannot be read', async () => {
mockReaddir
.mockResolvedValueOnce(['good', 'bad'])
.mockResolvedValueOnce(['s.jsonl']) // good
.mockRejectedValueOnce(new Error('EACCES')) // bad
mockStat.mockResolvedValue({ mtimeMs: 1000 })
mockOpen.mockResolvedValue(mockFile(JSON.stringify({ type: 'user', cwd: '/g', message: { role: 'user', content: 'hi' } })))
const out = await listSessions()
expect(out).toHaveLength(1)
expect(out[0]!.id).toBe('s')
})
it('respects the limit', async () => {
mockReaddir.mockResolvedValueOnce(['p']).mockResolvedValueOnce(['a.jsonl', 'b.jsonl', 'c.jsonl'])
mockStat.mockImplementation(async (p: string) => ({ mtimeMs: p.charCodeAt(p.length - 7) }))
mockOpen.mockResolvedValue(mockFile(JSON.stringify({ type: 'user', cwd: '/p', message: { role: 'user', content: 'x' } })))
const out = await listSessions(2)
expect(out).toHaveLength(2)
})
})

View File

@@ -227,9 +227,9 @@ function waitForMessage(
// ── Test suite ────────────────────────────────────────────────────────────────
describe('startServer — integration', () => {
// Suppress stray MaxListeners warnings: each startServer call adds SIGINT/SIGTERM/
// uncaughtException handlers. Tests use separate server instances per test.
process.setMaxListeners(50)
// NOTE: no process.setMaxListeners() bump here. The signal-handler-leak fix
// (CQ H1) means close() removes the SIGINT/SIGTERM/uncaughtException listeners
// it registered, so repeated startServer()/close() cycles don't accumulate.
let port: number
let cfg: Config
@@ -730,4 +730,151 @@ describe('startServer — integration', () => {
},
20_000,
)
// ── Signal-handler leak fix (CQ H1) ────────────────────────────────────────
it('does not leak SIGINT listeners across startServer()/close() cycles', async () => {
const before = process.listenerCount('SIGINT')
for (let i = 0; i < 3; i++) {
const p = await getFreePort()
const h = startServer(makeTestConfig(p, process.env['SHELL'] ?? '/bin/zsh'))
await new Promise<void>((r) => setTimeout(r, 30))
await h.close()
}
expect(process.listenerCount('SIGINT')).toBe(before)
expect(process.listenerCount('SIGTERM')).toBe(before)
})
// ── GET /live-sessions (route, no PTY needed when empty) ────────────────────
it('GET /live-sessions returns an array (empty when none running)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/live-sessions`)
expect(res.status).toBe(200)
const body = (await res.json()) as unknown
expect(Array.isArray(body)).toBe(true)
})
// ── GET /live-sessions/:id/preview — unknown id → 404 ───────────────────────
it('GET /live-sessions/:id/preview returns 404 for an unknown session id', async () => {
const res = await fetch(`http://127.0.0.1:${port}/live-sessions/00000000-0000-4000-8000-000000000000/preview`)
expect(res.status).toBe(404)
})
// ── Security headers present on responses ───────────────────────────────────
it('sets conservative security headers (CSP / X-Frame-Options / nosniff)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/live-sessions`)
expect(res.headers.get('x-frame-options')).toBe('DENY')
expect(res.headers.get('x-content-type-options')).toBe('nosniff')
expect(res.headers.get('content-security-policy')).toContain("default-src 'self'")
})
// ── DELETE /live-sessions Origin guard (Sec H2 / Arch 5b) ───────────────────
it('DELETE /live-sessions → 403 with a foreign Origin', async () => {
const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, {
method: 'DELETE',
headers: { Origin: 'http://evil.example' },
})
expect(res.status).toBe(403)
})
it('DELETE /live-sessions → 403 with NO Origin header (default-deny)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, { method: 'DELETE' })
expect(res.status).toBe(403)
})
it('DELETE /live-sessions → 200 with an allowed (same-host) Origin', async () => {
const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, {
method: 'DELETE',
headers: { Origin: `http://127.0.0.1:${port}` },
})
expect(res.status).toBe(200)
const body = (await res.json()) as { killed: number }
expect(typeof body.killed).toBe('number')
})
it('DELETE /live-sessions/:id → 403 with a foreign Origin (before 404 lookup)', async () => {
const res = await fetch(
`http://127.0.0.1:${port}/live-sessions/00000000-0000-4000-8000-000000000000`,
{ method: 'DELETE', headers: { Origin: 'http://evil.example' } },
)
expect(res.status).toBe(403)
})
it('DELETE /live-sessions/:id → 404 (allowed Origin, unknown id)', async () => {
const res = await fetch(
`http://127.0.0.1:${port}/live-sessions/00000000-0000-4000-8000-000000000000`,
{ method: 'DELETE', headers: { Origin: `http://127.0.0.1:${port}` } },
)
expect(res.status).toBe(404)
})
// ── Hook side-channel endpoints (route-level, no PTY needed) ────────────────
it('POST /hook from loopback with an unknown session → 204 (no-op broadcast)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/hook`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': '00000000-0000-4000-8000-000000000000' },
body: JSON.stringify({ hook_event_name: 'Stop' }),
})
expect(res.status).toBe(204)
})
it('POST /hook with an unparseable event body → 400', async () => {
const res = await fetch(`http://127.0.0.1:${port}/hook`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }, // no session header / no event name
body: JSON.stringify({}),
})
expect(res.status).toBe(400)
})
it('POST /hook/permission with no matching session falls back to {} (Claude prompts itself)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/hook/permission`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': '00000000-0000-4000-8000-000000000000' },
body: JSON.stringify({ tool_name: 'Bash' }),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({})
})
// ── Per-connection WS rate limit (Sec M3) ───────────────────────────────────
// Fires BEFORE attach, so we can flood pre-attach frames without a real PTY.
// A tiny MAX_MSGS_PER_SEC makes the cap observable: the connection survives a
// flood (frames are dropped, not closed).
it('drops frames over MAX_MSGS_PER_SEC but keeps the connection open (no close)', async () => {
const rlPort = await getFreePort()
const rlCfg = loadConfig({
PORT: String(rlPort),
BIND_HOST: '127.0.0.1',
SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
ALLOWED_ORIGINS: `http://127.0.0.1:${rlPort}`,
IDLE_TTL: '86400',
USE_TMUX: '0',
MAX_MSGS_PER_SEC: '5',
})
const rlServer = startServer(rlCfg)
await new Promise<void>((r) => setTimeout(r, 80))
try {
const ws = new WebSocket(`ws://127.0.0.1:${rlPort}${rlCfg.wsPath}`, {
headers: { Origin: `http://127.0.0.1:${rlPort}` },
})
await waitForOpen(ws, 3_000)
// Flood 50 invalid frames in one tick — far over the 5/s cap.
for (let i = 0; i < 50; i++) ws.send('not-json')
// The connection must NOT be closed by the rate limiter.
let closed = false
ws.once('close', () => {
closed = true
})
await new Promise<void>((r) => setTimeout(r, 200))
expect(closed).toBe(false)
expect(ws.readyState).toBe(WebSocket.OPEN)
ws.close()
await waitForClose(ws, 3_000).catch(() => undefined)
} finally {
await rlServer.close()
await new Promise<void>((r) => setTimeout(r, 50))
}
})
})

View File

@@ -46,6 +46,11 @@ const CFG: Config = {
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: [],
};
@@ -509,6 +514,105 @@ describe('shutdown', () => {
});
});
// ── maxSessions DoS cap (Sec H1) ──────────────────────────────────────────────
describe('handleAttach — session cap', () => {
it('throws once the table is at cfg.maxSessions (new-session path)', () => {
const cappedCfg: Config = { ...CFG, maxSessions: 2 };
const mgr = createSessionManager(cappedCfg);
nextPty = createMockPty();
mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
nextPty = createMockPty();
mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
// Third new session must be refused (reuses the M4 exit(-1) path in server).
nextPty = createMockPty();
expect(() => mgr.handleAttach(createMockWs(), null, DIMS, 1_000)).toThrow(/limit/i);
});
it('also caps the not-found path (unknown id → would create)', () => {
const cappedCfg: Config = { ...CFG, maxSessions: 1 };
const mgr = createSessionManager(cappedCfg);
mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
const unknown = '00000000-0000-4000-8000-000000000099';
nextPty = createMockPty();
expect(() => mgr.handleAttach(createMockWs(), unknown, DIMS, 1_000)).toThrow(/limit/i);
});
it('JOINing an existing session does NOT count against the cap', () => {
const cappedCfg: Config = { ...CFG, maxSessions: 1 };
const mgr = createSessionManager(cappedCfg);
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
// A second device joining the same session must be allowed at the cap.
expect(() => mgr.handleAttach(createMockWs(), s.meta.id, DIMS, 2_000)).not.toThrow();
expect(s.clients.size).toBe(2);
});
});
// ── killById (manage page) ────────────────────────────────────────────────────
describe('killById', () => {
it('closes all clients, kills the PTY, and removes the session', () => {
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);
const ok = mgr.killById(s.meta.id);
expect(ok).toBe(true);
expect(a.closed).toBe(true);
expect(b.closed).toBe(true);
expect((s.pty as MockIPty).killed).toBe(true);
expect(mgr.get(s.meta.id)).toBeUndefined();
});
it('returns false for an unknown id', () => {
const mgr = createSessionManager(CFG);
expect(mgr.killById('00000000-0000-4000-8000-0000000000aa')).toBe(false);
});
});
// ── handleHookEvent (H2/H3 status push) ───────────────────────────────────────
describe('handleHookEvent', () => {
it('sets claudeStatus and broadcasts a status frame 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;
mgr.handleHookEvent(s.meta.id, 'waiting', 'Bash', true);
expect(s.claudeStatus).toBe('waiting');
for (const ws of [a, b]) {
const status = parseSent(ws).find((m) => m.type === 'status');
expect(status).toMatchObject({ type: 'status', status: 'waiting', detail: 'Bash', pending: true });
}
});
it('omits detail/pending 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, 'idle');
const status = parseSent(ws).find((m) => m.type === 'status');
expect(status).toEqual({ type: 'status', status: 'idle' });
});
it('is a no-op for an unknown session id', () => {
const mgr = createSessionManager(CFG);
expect(() => mgr.handleHookEvent('00000000-0000-4000-8000-0000000000bb', 'working')).not.toThrow();
});
});
// ── L2: injected onExit removes session when ws is attached at exit time ───────
describe('onExit removes session from table when ws attached at exit time (L2)', () => {
it('session is removed from the table when PTY exits while ws is attached', () => {

171
test/preview-grid.test.ts Normal file
View File

@@ -0,0 +1,171 @@
// @vitest-environment jsdom
/**
* test/preview-grid.test.ts — shared launcher/manage preview helpers.
*
* xterm Terminal is stubbed; fetch is mocked. Covers the DRY core extracted in
* Phase 4: formatting, the card factory, fit scaling, and the preview/list
* fetch helpers (best-effort, no throw on failure).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { LiveSessionInfo } from '../src/types.js'
class FakeTerminal {
cols = 80
rows = 24
open = vi.fn()
reset = vi.fn()
resize = vi.fn()
dispose = vi.fn()
write = vi.fn((_d: string, cb?: () => void) => cb?.())
}
vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal }))
const mod = await import('../public/preview-grid.js')
const {
el,
relTime,
statusText,
sessionName,
makePreviewCard,
updatePreviewCard,
fitThumb,
fetchPreview,
renderPreview,
loadPreviewInto,
fetchLiveSessions,
} = mod
function session(over: Partial<LiveSessionInfo> = {}): LiveSessionInfo {
return {
id: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
createdAt: Date.now() - 5000,
clientCount: 0,
status: 'idle',
exited: false,
cwd: '/work/proj',
cols: 80,
rows: 24,
...over,
}
}
beforeEach(() => {
vi.restoreAllMocks()
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0)
return 0
})
})
describe('formatting helpers', () => {
it('el builds an element with class + text', () => {
const n = el('div', 'x', 'hi')
expect(n.className).toBe('x')
expect(n.textContent).toBe('hi')
})
it('relTime renders s/m/h/d buckets', () => {
const now = Date.now()
expect(relTime(now)).toMatch(/^\d+s$/)
expect(relTime(now - 120_000)).toBe('2m')
expect(relTime(now - 2 * 3600_000)).toBe('2h')
expect(relTime(now - 3 * 86400_000)).toBe('3d')
})
it('statusText maps each status', () => {
expect(statusText('working')).toContain('working')
expect(statusText('waiting')).toContain('waiting')
expect(statusText('idle')).toContain('idle')
expect(statusText('unknown')).toBe('·')
})
it('sessionName uses last cwd segment, else short id', () => {
expect(sessionName(session({ cwd: '/a/b/cool' }))).toBe('cool')
expect(sessionName(session({ cwd: null }))).toBe('aaaaaaaa')
})
})
describe('makePreviewCard / updatePreviewCard', () => {
it('builds a card and fires onOpen when the thumb is clicked (button variant)', () => {
const onOpen = vi.fn()
const card = makePreviewCard(session(), { onOpen })
card.thumb.click()
expect(onOpen).toHaveBeenCalledWith(session().id)
// The Open control is a <button> when no openHref is given.
expect(card.el.querySelector('button.mg-open')).not.toBeNull()
})
it('uses an <a href> Open + extra actions when configured (manage variant)', () => {
const extra = el('button', 'mg-kill', 'Kill')
const card = makePreviewCard(session(), {
onOpen: vi.fn(),
openHref: (id) => `/?join=${id}`,
extraActions: () => [extra],
})
const open = card.el.querySelector('a.mg-open') as HTMLAnchorElement | null
expect(open?.getAttribute('href')).toBe(`/?join=${session().id}`)
expect(card.el.contains(extra)).toBe(true)
})
it('updatePreviewCard refreshes status + watch in place', () => {
const card = makePreviewCard(session(), { onOpen: vi.fn() })
updatePreviewCard(card, session({ status: 'working', clientCount: 3 }))
expect(card.status.textContent).toContain('working')
expect(card.watch.textContent).toBe('👁 3')
expect(card.watch.className).toContain('live')
})
})
describe('fitThumb', () => {
it('no-ops when the inner element has no measured size', () => {
const card = makePreviewCard(session(), { onOpen: vi.fn() })
// jsdom offsetWidth/Height are 0 → no transform applied.
fitThumb(card, 320, 200)
expect(card.inner.style.transform).toBe('')
})
it('scales and clamps height when inner has a size', () => {
const card = makePreviewCard(session(), { onOpen: vi.fn() })
Object.defineProperty(card.inner, 'offsetWidth', { value: 640, configurable: true })
Object.defineProperty(card.inner, 'offsetHeight', { value: 400, configurable: true })
fitThumb(card, 320, 200)
expect(card.inner.style.transform).toBe('scale(0.5)')
expect(card.thumb.style.height).toBe('200px')
})
})
describe('fetch helpers', () => {
it('fetchPreview returns parsed JSON on 200', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ id: 'x', cols: 80, rows: 24, data: 'hi' }) })))
expect(await fetchPreview('x')).toMatchObject({ id: 'x', data: 'hi' })
})
it('fetchPreview returns null on non-ok / throw', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, json: async () => ({}) })))
expect(await fetchPreview('x')).toBeNull()
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('net') }))
expect(await fetchPreview('x')).toBeNull()
})
it('renderPreview writes the cleared tail and resizes when dims differ', () => {
const card = makePreviewCard(session(), { onOpen: vi.fn() })
renderPreview(card, { id: 'x', cols: 100, rows: 30, data: 'screen' }, 320, 200)
expect(card.term.resize).toHaveBeenCalledWith(100, 30)
expect(card.term.write).toHaveBeenCalled()
})
it('loadPreviewInto fetches then renders (no throw on failure)', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ id: 'x', cols: 80, rows: 24, data: 'd' }) })))
const card = makePreviewCard(session(), { onOpen: vi.fn() })
await expect(loadPreviewInto('x', card, 320, 200)).resolves.toBeUndefined()
expect(card.term.write).toHaveBeenCalled()
})
it('fetchLiveSessions returns [] on failure and array on success', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ json: async () => [session()] })))
expect(await fetchLiveSessions()).toHaveLength(1)
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('x') }))
expect(await fetchLiveSessions()).toEqual([])
})
})

View File

@@ -117,10 +117,9 @@ describe('parseClientMessage — valid messages', () => {
if (r.ok) expect(r.message).toEqual({ type: 'reject' })
})
it('parses blur (v0.4, no payload — withdraw size vote)', () => {
it('rejects blur (removed in the min→latest-writer pivot)', () => {
const b = parseClientMessage(JSON.stringify({ type: 'blur' }))
expect(b.ok).toBe(true)
if (b.ok) expect(b.message).toEqual({ type: 'blur' })
expect(b.ok).toBe(false)
})
})

View File

@@ -33,7 +33,7 @@ vi.mock('node-pty', () => ({
}));
// Imported AFTER vi.mock so the module under test binds to the mocked spawn.
const { createSession, attachWs, detachWs, writeInput, setClientDims, clearClientDims, kill } =
const { createSession, attachWs, detachWs, writeInput, setClientDims, kill } =
await import('../src/session/session.js');
// ── helpers ─────────────────────────────────────────────────────────────────
@@ -46,6 +46,11 @@ const CFG: Config = {
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: [],
};
@@ -241,18 +246,6 @@ describe('attachWs', () => {
expect(s.pty.rows).toBe(50);
});
it('clearClientDims (tab hidden) does NOT resize — the active device keeps its size', () => {
const s = newSession();
const a = createMockWs();
const b = createMockWs();
setClientDims(s, a, 200, 50);
setClientDims(s, b, 90, 30); // b is using it now → 90x30
clearClientDims(s, a); // a's tab hidden elsewhere → just forget a's dims
// PTY unchanged: b is still the active viewer at 90x30.
expect(s.pty.cols).toBe(90);
expect(s.pty.rows).toBe(30);
});
});
// ── detachWs (remove one client) ──────────────────────────────────────────────

348
test/tabs.test.ts Normal file
View File

@@ -0,0 +1,348 @@
// @vitest-environment jsdom
/**
* test/tabs.test.ts — frontend TabApp unit tests.
*
* Mocks TerminalSession + the launcher so we test TabApp's tab lifecycle in
* isolation: the v0.5 "close last tab → launcher" invariant and that addEntry
* builds a real (non-null) session before pushing the entry (Phase 1#5 — the
* `null as unknown as TerminalSession` hole is gone).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ── Mock TerminalSession ──────────────────────────────────────────────────────
let constructed = 0
class FakeTerminalSession {
el: HTMLDivElement
id: string | null
status = 'connecting'
claudeStatus = 'unknown'
cwd: string | null = null
pendingApproval = false
connect = vi.fn()
dispose = vi.fn()
show = vi.fn()
hide = vi.fn()
applyTheme = vi.fn()
refit = vi.fn()
send = vi.fn()
approve = vi.fn()
reject = vi.fn()
findNext = vi.fn()
findPrevious = vi.fn()
clearSearch = vi.fn()
// Captured callbacks so tests can drive status/title/activity events.
cbs: {
onClaudeStatus?: (s: string, d?: string) => void
onStatus?: (s: string) => void
onActivity?: () => void
onTitle?: (t: string) => void
}
static instances: FakeTerminalSession[] = []
constructor(opts: {
sessionId: string | null
onClaudeStatus?: (s: string, d?: string) => void
onStatus?: (s: string) => void
onActivity?: () => void
onTitle?: (t: string) => void
}) {
constructed += 1
this.id = opts.sessionId
this.el = document.createElement('div')
this.cbs = opts
FakeTerminalSession.instances.push(this)
}
}
vi.mock('../public/terminal-session.js', () => ({ TerminalSession: FakeTerminalSession }))
// ── Mock the launcher (records visibility) ────────────────────────────────────
const launcherVisible = { value: false }
const mountLauncher = vi.fn(() => ({
setVisible: (v: boolean) => {
launcherVisible.value = v
},
refresh: vi.fn(),
}))
vi.mock('../public/launcher.js', () => ({ mountLauncher }))
// settings.js is light but imports nothing heavy; let it load for real.
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)
return { paneHost, tabBar }
}
beforeEach(() => {
constructed = 0
FakeTerminalSession.instances = []
launcherVisible.value = false
document.body.replaceChildren()
localStorage.clear()
})
describe('TabApp — v0.5 launcher chooser', () => {
it('starts with NO auto-created tab and shows the launcher', () => {
const { paneHost, tabBar } = makeHosts()
new TabApp(paneHost, tabBar)
// No TerminalSession constructed on boot (no auto tab).
expect(constructed).toBe(0)
expect(launcherVisible.value).toBe(true)
})
it('newTab() creates a tab and hides the launcher', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
expect(constructed).toBe(1)
expect(launcherVisible.value).toBe(false)
})
it('closing the last tab returns to the launcher (no auto-blank tab)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
expect(launcherVisible.value).toBe(false)
app.closeTab(0)
// Back to the chooser, and no new tab was auto-created.
expect(launcherVisible.value).toBe(true)
expect(constructed).toBe(1)
})
})
describe('TabApp — addEntry has no null-session hole (Phase 1#5)', () => {
it('the constructed session is real and used immediately (connect called)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
// The entry's session must be the constructed FakeTerminalSession with connect() called.
const snap = app.snapshot()
expect(snap).toHaveLength(1)
// sendToActive routes to a real session (would throw if session were null).
expect(() => app.sendToActive('x')).not.toThrow()
})
it('openSession joins by id and focuses it', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.openSession('11111111-1111-4111-8111-111111111111')
expect(constructed).toBe(1)
expect(app.activeSessionId()).toBe('11111111-1111-4111-8111-111111111111')
})
it('openSession re-focuses an already-open session instead of duplicating', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
const id = '22222222-2222-4222-8222-222222222222'
app.openSession(id)
app.openSession(id)
expect(constructed).toBe(1) // not duplicated
})
})
describe('TabApp — multi-tab lifecycle', () => {
it('renders a tab bar with one .tab per tab plus the add button', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
app.newTab()
expect(tabBar.querySelectorAll('.tab')).toHaveLength(2)
expect(tabBar.querySelector('.tab-add')).not.toBeNull()
})
it('activate() switches the active tab (show/hide on sessions)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
app.newTab()
app.activate(0)
const snap = app.snapshot()
expect(snap.find((t) => t.active)?.idx).toBe(0)
})
it('closeTab on a middle tab keeps the others and re-activates a neighbor', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
app.newTab()
app.newTab()
app.closeTab(1)
expect(app.snapshot()).toHaveLength(2)
})
it('applySettings re-themes every tab without throwing', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
expect(() => app.applySettings({ theme: 'dark', fontSize: 15 })).not.toThrow()
})
it('findInActive / clearActiveSearch route to the active session', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
expect(() => {
app.findInActive('q', 'next')
app.findInActive('q', 'prev')
app.clearActiveSearch()
app.refitActive()
}).not.toThrow()
})
it('newTabForResume opens a tab seeded with a resume command', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTabForResume('/work', '33333333-3333-4333-8333-333333333333')
expect(constructed).toBe(1)
expect(app.snapshot()).toHaveLength(1)
})
it('snapshot reflects per-tab connection + claude status fields', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
const snap = app.snapshot()
expect(snap[0]).toMatchObject({ idx: 0, conn: 'connecting', claude: 'unknown' })
})
})
describe('TabApp — DOM interactions on the tab bar', () => {
function pointerdown(elm: Element): void {
elm.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
}
it('pointerdown on a tab activates it', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
app.newTab()
const tabs = tabBar.querySelectorAll('.tab')
pointerdown(tabs[0]!)
expect(app.snapshot().find((t) => t.active)?.idx).toBe(0)
})
it('clicking the × close button closes that tab', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
app.newTab()
const close = tabBar.querySelector('.tab-close') as HTMLButtonElement
close.dispatchEvent(new MouseEvent('click', { bubbles: true }))
expect(app.snapshot()).toHaveLength(1)
})
it('clicking the + add button opens a new tab', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
const add = tabBar.querySelector('.tab-add') as HTMLButtonElement
add.click()
expect(app.snapshot()).toHaveLength(1)
})
it('middle-click (auxclick button 1) closes a tab', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
app.newTab()
const tab = tabBar.querySelector('.tab') as HTMLElement
tab.dispatchEvent(new MouseEvent('auxclick', { bubbles: true, button: 1 }))
expect(app.snapshot()).toHaveLength(1)
})
it('double-click the label enters rename mode, Enter commits a custom title', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
const label = tabBar.querySelector('.tab-label') as HTMLElement
label.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
const input = tabBar.querySelector('input.tab-rename') as HTMLInputElement
expect(input).not.toBeNull()
input.value = 'My Tab'
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
expect(app.snapshot()[0]!.title).toBe('My Tab')
})
it('activate() out of range is ignored', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
expect(() => {
app.activate(99)
app.activate(-1)
app.focusTab(0)
}).not.toThrow()
})
it('sendToActive / clearActiveSearch with no active tab do not throw', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
// No tab open (launcher visible).
expect(() => {
app.sendToActive('x')
app.clearActiveSearch()
app.refitActive()
}).not.toThrow()
expect(app.activeSessionId()).toBeNull()
})
it('Escape in rename mode cancels without committing', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
const label = tabBar.querySelector('.tab-label') as HTMLElement
label.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
const input = tabBar.querySelector('input.tab-rename') as HTMLInputElement
input.value = 'discard me'
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
// Title falls back to the auto/default, not the typed value.
expect(app.snapshot()[0]!.title).not.toBe('discard me')
})
it('onTitle/onStatus/onActivity callbacks refresh the tab in place', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
const fake = FakeTerminalSession.instances[0]!
// Drive the captured callbacks like the real TerminalSession would.
expect(() => {
fake.cbs.onTitle?.('myproj')
fake.cbs.onStatus?.('connected')
fake.cbs.onActivity?.()
}).not.toThrow()
})
it('onClaudeStatus "waiting" on a background tab notifies (H2/H4)', () => {
const NotificationMock = vi.fn()
;(NotificationMock as unknown as { permission: string }).permission = 'granted'
vi.stubGlobal('Notification', NotificationMock)
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab() // tab 0 (active)
app.newTab() // tab 1 (active now)
// tab 0 is in the background; fire its onClaudeStatus 'waiting'.
const bg = FakeTerminalSession.instances[0]!
bg.claudeStatus = 'waiting'
bg.cbs.onClaudeStatus?.('waiting')
expect(NotificationMock).toHaveBeenCalled()
vi.unstubAllGlobals()
})
it('drag-and-drop reorders tabs', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
app.newTab()
const tabs = tabBar.querySelectorAll('.tab')
// jsdom has no DataTransfer; the handlers use optional chaining + this.dragIndex,
// so plain drag events (no dataTransfer) still drive the reorder path.
tabs[0]!.dispatchEvent(new Event('dragstart', { bubbles: true }))
tabs[1]!.dispatchEvent(new Event('dragover', { bubbles: true }))
tabs[1]!.dispatchEvent(new Event('drop', { bubbles: true }))
tabs[0]!.dispatchEvent(new Event('dragend', { bubbles: true }))
expect(app.snapshot()).toHaveLength(2)
})
})

View File

@@ -0,0 +1,458 @@
// @vitest-environment jsdom
/**
* test/terminal-session.test.ts — frontend TerminalSession unit tests.
*
* Runs under jsdom with a mock WebSocket and a stubbed xterm Terminal so we can
* exercise the reconnect state machine, buildWsUrl scheme selection, the
* disposed-guard on the initialInput timer (Phase 1#4 regression), hide()
* cleanup, and status handling — without a real browser or server.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// ── Stub xterm + addons (heavy DOM/canvas deps we don't need here) ────────────
class FakeTerminal {
options: Record<string, unknown> = {}
cols = 80
rows = 24
parser = { registerOscHandler: vi.fn() }
loadAddon = vi.fn()
open = vi.fn()
write = vi.fn()
focus = vi.fn()
dispose = vi.fn()
private dataCbs: Array<(d: string) => void> = []
onData = (cb: (d: string) => void) => {
this.dataCbs.push(cb)
return { dispose: vi.fn() }
}
onTitleChange = vi.fn(() => ({ dispose: vi.fn() }))
emitData(d: string): void {
for (const cb of this.dataCbs) cb(d)
}
}
vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal }))
vi.mock('@xterm/addon-fit', () => ({
FitAddon: class {
fit = vi.fn()
},
}))
vi.mock('@xterm/addon-search', () => ({
SearchAddon: class {
findNext = vi.fn()
findPrevious = vi.fn()
clearDecorations = vi.fn()
},
}))
vi.mock('@xterm/addon-web-links', () => ({ WebLinksAddon: class {} }))
// ── Mock WebSocket ────────────────────────────────────────────────────────────
class MockWebSocket {
static OPEN = 1
static instances: MockWebSocket[] = []
static last(): MockWebSocket {
return MockWebSocket.instances[MockWebSocket.instances.length - 1]!
}
url: string
readyState = 0 // CONNECTING
sent: string[] = []
private listeners: Record<string, Array<(e: unknown) => void>> = {}
constructor(url: string) {
this.url = url
MockWebSocket.instances.push(this)
}
addEventListener(type: string, cb: (e: unknown) => void): void {
;(this.listeners[type] ??= []).push(cb)
}
send(data: string): void {
this.sent.push(data)
}
close(): void {
this.readyState = 3
this.fire('close', {})
}
// test helpers
fire(type: string, e: unknown): void {
for (const cb of this.listeners[type] ?? []) cb(e)
}
openIt(): void {
this.readyState = MockWebSocket.OPEN
this.fire('open', {})
}
message(data: string): void {
this.fire('message', { data })
}
}
// ResizeObserver is absent in jsdom.
class FakeResizeObserver {
observe = vi.fn()
disconnect = vi.fn()
constructor(_cb: () => void) {}
}
// ── Wire globals, then import the module under test ───────────────────────────
beforeEach(() => {
MockWebSocket.instances = []
vi.stubGlobal('WebSocket', MockWebSocket)
vi.stubGlobal('ResizeObserver', FakeResizeObserver)
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0)
return 0
})
})
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
})
const { TerminalSession } = await import('../public/terminal-session.js')
function setLocation(protocol: string, host: string): void {
vi.stubGlobal('location', { protocol, host })
}
describe('buildWsUrl scheme selection (M6)', () => {
it('uses ws:// on http pages', () => {
setLocation('http:', 'lan:3000')
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
s.connect()
expect(MockWebSocket.last().url).toBe('ws://lan:3000/term')
})
it('uses wss:// on https pages (Tailscale/TLS, avoids mixed content)', () => {
setLocation('https:', 'host.ts.net')
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
s.connect()
expect(MockWebSocket.last().url).toBe('wss://host.ts.net/term')
})
})
describe('attach handshake', () => {
beforeEach(() => setLocation('http:', 'lan:3000'))
it('sends attach{sessionId:null} on open for a fresh session', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
s.connect()
MockWebSocket.last().openIt()
expect(JSON.parse(MockWebSocket.last().sent[0]!)).toEqual({ type: 'attach', sessionId: null })
})
it('persists the assigned id from the attached frame', () => {
const onSessionId = vi.fn()
const s = new TerminalSession({ sessionId: null, onSessionId })
s.connect()
MockWebSocket.last().openIt()
MockWebSocket.last().message(JSON.stringify({ type: 'attached', sessionId: 'sid-1' }))
expect(onSessionId).toHaveBeenCalledWith('sid-1')
expect(s.id).toBe('sid-1')
})
})
describe('status frame (H3 pending approval)', () => {
beforeEach(() => setLocation('http:', 'lan:3000'))
it('sets pendingApproval / claudeStatus from a status frame', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
s.connect()
MockWebSocket.last().openIt()
MockWebSocket.last().message(JSON.stringify({ type: 'status', status: 'waiting', detail: 'Bash', pending: true }))
expect(s.claudeStatus).toBe('waiting')
expect(s.pendingApproval).toBe(true)
expect(s.pendingTool).toBe('Bash')
})
})
describe('reconnect backoff', () => {
beforeEach(() => {
setLocation('http:', 'lan:3000')
vi.useFakeTimers()
})
it('doubles the delay each attempt and caps at 30s', () => {
const s = new TerminalSession({ sessionId: 'sid', onSessionId: vi.fn() })
s.connect()
const delays: number[] = []
const origSetTimeout = globalThis.setTimeout
vi.spyOn(globalThis, 'setTimeout').mockImplementation(((fn: () => void, ms?: number) => {
delays.push(ms ?? 0)
return origSetTimeout(fn, 0)
}) as typeof setTimeout)
// Simulate repeated close→reconnect cycles.
for (let i = 0; i < 7; i++) {
MockWebSocket.last().fire('close', {})
vi.runOnlyPendingTimers()
}
// First delay 1000, then 2000, 4000 … capped at 30000.
expect(delays[0]).toBe(1000)
expect(delays[1]).toBe(2000)
expect(delays[2]).toBe(4000)
expect(Math.max(...delays)).toBeLessThanOrEqual(30_000)
expect(delays[delays.length - 1]).toBe(30_000)
})
})
describe('initialInput timer respects dispose (Phase 1#4 regression)', () => {
beforeEach(() => {
setLocation('http:', 'lan:3000')
vi.useFakeTimers()
})
it('does NOT send the initial command if disposed before the timer fires', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn(), initialInput: 'claude --resume x\r' })
s.connect()
const ws = MockWebSocket.last()
ws.openIt()
ws.message(JSON.stringify({ type: 'attached', sessionId: 'sid' }))
ws.sent.length = 0 // drop attach/resize frames
s.dispose() // dispose BEFORE the 700ms initial-input timer fires
vi.advanceTimersByTime(2000)
// No input frame should have been sent after dispose.
const inputs = ws.sent.map((x) => JSON.parse(x)).filter((m) => m.type === 'input')
expect(inputs).toHaveLength(0)
})
it('DOES send the initial command when not disposed', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn(), initialInput: 'echo hi\r' })
s.connect()
const ws = MockWebSocket.last()
ws.openIt()
ws.message(JSON.stringify({ type: 'attached', sessionId: 'sid' }))
ws.sent.length = 0
vi.advanceTimersByTime(800)
const inputs = ws.sent.map((x) => JSON.parse(x)).filter((m) => m.type === 'input')
expect(inputs).toContainEqual({ type: 'input', data: 'echo hi\r' })
})
})
describe('hide() does not send a blur frame (removed in latest-writer pivot)', () => {
beforeEach(() => setLocation('http:', 'lan:3000'))
it('hide() sends nothing and never emits type:"blur"', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
s.connect()
const ws = MockWebSocket.last()
ws.openIt()
ws.sent.length = 0
s.hide()
const blur = ws.sent.map((x) => JSON.parse(x)).filter((m) => m.type === 'blur')
expect(blur).toHaveLength(0)
})
})
describe('dispose() tears down cleanly', () => {
beforeEach(() => setLocation('http:', 'lan:3000'))
it('closes the WS and does not reconnect afterwards', () => {
const s = new TerminalSession({ sessionId: 'sid', onSessionId: vi.fn() })
s.connect()
const ws = MockWebSocket.last()
ws.openIt()
const countBefore = MockWebSocket.instances.length
s.dispose()
// A close after dispose must NOT trigger a new connection.
ws.fire('close', {})
expect(MockWebSocket.instances.length).toBe(countBefore)
})
it('connect() is a no-op after dispose', () => {
const s = new TerminalSession({ sessionId: 'sid', onSessionId: vi.fn() })
s.dispose()
s.connect()
expect(MockWebSocket.instances).toHaveLength(0)
})
})
describe('output + activity + send paths', () => {
beforeEach(() => setLocation('http:', 'lan:3000'))
function connected(opts: { onActivity?: () => void } = {}) {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn(), ...opts })
s.connect()
const ws = MockWebSocket.last()
ws.openIt()
return { s, ws }
}
it('writes output to the terminal and fires onActivity for a hidden pane', () => {
const onActivity = vi.fn()
const { s, ws } = connected({ onActivity })
// pane starts display:none (hidden) → output triggers activity
ws.message(JSON.stringify({ type: 'output', data: 'hello' }))
expect(onActivity).toHaveBeenCalled()
// show() flips display to block → no further activity on next output
s.show()
onActivity.mockClear()
ws.message(JSON.stringify({ type: 'output', data: 'more' }))
expect(onActivity).not.toHaveBeenCalled()
})
it('send() routes keyboard bytes as an input frame', () => {
const { s, ws } = connected()
ws.sent.length = 0
s.send('ls\r')
expect(JSON.parse(ws.sent[0]!)).toEqual({ type: 'input', data: 'ls\r' })
})
it('approve()/reject() send their frames and clear pendingApproval', () => {
const { s, ws } = connected()
ws.message(JSON.stringify({ type: 'status', status: 'waiting', detail: 'Bash', pending: true }))
ws.sent.length = 0
s.approve()
expect(JSON.parse(ws.sent[0]!)).toEqual({ type: 'approve' })
expect(s.pendingApproval).toBe(false)
s.reject()
expect(JSON.parse(ws.sent[1]!)).toEqual({ type: 'reject' })
})
it('ignores malformed JSON frames without throwing', () => {
const { ws } = connected()
expect(() => ws.message('{not json')).not.toThrow()
})
})
describe('exit handling', () => {
beforeEach(() => setLocation('http:', 'lan:3000'))
it('sets status to exited and writes the exit line', () => {
const onStatus = vi.fn()
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn(), onStatus })
s.connect()
const ws = MockWebSocket.last()
ws.openIt()
ws.message(JSON.stringify({ type: 'exit', code: 0 }))
expect(s.status).toBe('exited')
expect(onStatus).toHaveBeenCalledWith('exited')
})
it('exit with a reason includes it in the rendered line', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
s.connect()
const ws = MockWebSocket.last()
ws.openIt()
expect(() => ws.message(JSON.stringify({ type: 'exit', code: -1, reason: 'spawn failed' }))).not.toThrow()
})
})
describe('search + theme passthrough', () => {
beforeEach(() => setLocation('http:', 'lan:3000'))
it('findNext/findPrevious/clearSearch are callable', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
expect(() => {
s.findNext('x')
s.findPrevious('x')
s.clearSearch()
}).not.toThrow()
})
it('applyTheme updates terminal options', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
expect(() => s.applyTheme({ background: '#000' }, 14)).not.toThrow()
})
})
describe('attach with cwd (M6 "new tab here")', () => {
beforeEach(() => setLocation('http:', 'lan:3000'))
it('includes cwd in the attach frame for a fresh session', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn(), cwd: '/work/here' })
s.connect()
MockWebSocket.last().openIt()
expect(JSON.parse(MockWebSocket.last().sent[0]!)).toEqual({ type: 'attach', sessionId: null, cwd: '/work/here' })
})
it('omits cwd on a reconnect to an existing sessionId', () => {
const s = new TerminalSession({ sessionId: '44444444-4444-4444-8444-444444444444', onSessionId: vi.fn(), cwd: '/x' })
s.connect()
MockWebSocket.last().openIt()
expect(JSON.parse(MockWebSocket.last().sent[0]!)).toEqual({
type: 'attach',
sessionId: '44444444-4444-4444-8444-444444444444',
})
})
})
describe('exit → press Enter reconnects with a fresh session', () => {
beforeEach(() => setLocation('http:', 'lan:3000'))
it('Enter after exit closes the old WS and opens a new connection (sessionId reset)', () => {
const s = new TerminalSession({ sessionId: '55555555-5555-4555-8555-555555555555', onSessionId: vi.fn() })
s.connect()
const ws1 = MockWebSocket.last()
ws1.openIt()
ws1.message(JSON.stringify({ type: 'exit', code: 0 }))
const before = MockWebSocket.instances.length
// The exit handler registers a term.onData listener; pressing Enter ('\r')
// triggers reconnect. Drive it via the fake terminal's data callback.
;(s as unknown as { term: { emitData(d: string): void } }).term.emitData('\r')
expect(MockWebSocket.instances.length).toBe(before + 1)
expect(s.id).toBeNull() // fresh session id on reconnect
})
})
describe('show()/refit() re-fit when visible', () => {
beforeEach(() => setLocation('http:', 'lan:3000'))
it('show() makes the pane visible and focuses without throwing', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
s.connect()
MockWebSocket.last().openIt()
expect(() => s.show()).not.toThrow()
expect(s.el.style.display).toBe('block')
})
it('refit() is a no-op while hidden, runs when visible', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
s.connect()
MockWebSocket.last().openIt()
expect(() => s.refit()).not.toThrow() // hidden → no-op
s.show()
expect(() => s.refit()).not.toThrow() // visible → fits
})
it('safefit returns null on NaN dims → no resize frame sent (display:none guard, §9)', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
s.connect()
const ws = MockWebSocket.last()
ws.openIt()
// Make the fake terminal report NaN cols (simulating fit() while hidden).
;(s as unknown as { term: { cols: number; rows: number } }).term.cols = NaN
ws.sent.length = 0
s.show()
const resizes = ws.sent.map((x) => JSON.parse(x)).filter((m) => m.type === 'resize')
expect(resizes).toHaveLength(0)
})
it('status frame WITHOUT pending leaves pendingApproval false', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
s.connect()
MockWebSocket.last().openIt()
MockWebSocket.last().message(JSON.stringify({ type: 'status', status: 'working' }))
expect(s.claudeStatus).toBe('working')
expect(s.pendingApproval).toBe(false)
expect(s.pendingTool).toBeUndefined()
})
it('send() while the WS is closed is a no-op (guarded)', () => {
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
s.connect()
const ws = MockWebSocket.last()
ws.openIt()
ws.readyState = 3 // CLOSED
ws.sent.length = 0
s.send('x')
expect(ws.sent).toHaveLength(0)
})
})

70
test/tmux.test.ts Normal file
View File

@@ -0,0 +1,70 @@
/**
* test/tmux.test.ts (H1) — thin tmux CLI wrappers.
*
* execFileSync is mocked so no real tmux binary runs: every wrapper is
* best-effort and must NOT throw (a throwing exec → false / no-op).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockExec = vi.fn()
vi.mock('node:child_process', () => ({
execFileSync: (...a: unknown[]) => mockExec(...a),
}))
const { tmuxAvailable, tmuxName, hasSession, killSession } = await import('../src/session/tmux.js')
beforeEach(() => {
mockExec.mockReset()
})
describe('tmuxName', () => {
it('prefixes the session id with web_', () => {
expect(tmuxName('abc')).toBe('web_abc')
})
})
describe('tmuxAvailable', () => {
it('returns true when `tmux -V` succeeds', () => {
mockExec.mockReturnValue(Buffer.from('tmux 3.4'))
expect(tmuxAvailable()).toBe(true)
expect(mockExec).toHaveBeenCalledWith('tmux', ['-V'], { stdio: 'ignore' })
})
it('returns false when tmux is missing (exec throws)', () => {
mockExec.mockImplementation(() => {
throw new Error('ENOENT')
})
expect(tmuxAvailable()).toBe(false)
})
})
describe('hasSession', () => {
it('returns true when has-session exits 0', () => {
mockExec.mockReturnValue(Buffer.from(''))
expect(hasSession('web_x')).toBe(true)
expect(mockExec).toHaveBeenCalledWith('tmux', ['has-session', '-t', 'web_x'], { stdio: 'ignore' })
})
it('returns false when has-session throws (no such session)', () => {
mockExec.mockImplementation(() => {
throw new Error("can't find session")
})
expect(hasSession('web_gone')).toBe(false)
})
})
describe('killSession', () => {
it('invokes tmux kill-session for the name', () => {
mockExec.mockReturnValue(Buffer.from(''))
killSession('web_x')
expect(mockExec).toHaveBeenCalledWith('tmux', ['kill-session', '-t', 'web_x'], { stdio: 'ignore' })
})
it('swallows errors when the session is already gone', () => {
mockExec.mockImplementation(() => {
throw new Error('no session')
})
expect(() => killSession('web_gone')).not.toThrow()
})
})

View File

@@ -6,10 +6,29 @@ export default defineConfig({
environment: 'node',
// Scaffold has no tests yet; don't fail the script until modules add theirs.
passWithNoTests: true,
// Coverage target is the global 80% rule; enable with `--coverage` once modules exist.
// Coverage target is the global 80% rule; enforced with `--coverage`.
// Scope: all backend src/** (the review's core), plus the frontend modules
// that carry real logic and have unit tests. The remaining public/*.ts are
// thin DOM-wiring / entry-point glue (main.ts boots the app; dashboard/qr/
// share/shortcuts/search/keybar/settings/history just build + wire DOM) with
// no branch logic worth unit-testing in this fix-pass — excluded so the
// threshold measures tested surface, not untestable wiring. (Browser-driven
// E2E, not unit tests, is the right tool for those.)
coverage: {
provider: 'v8',
include: ['src/**/*.ts', 'public/**/*.ts'],
include: [
'src/**/*.ts',
'public/terminal-session.ts',
'public/tabs.ts',
'public/preview-grid.ts',
'public/title-util.ts',
],
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
},
})