Compare commits

...
Author SHA1 Message Date
arkonandClaude Opus 4.7 6280998bd8 chore: version packages
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 20:22:30 +02:00
arkonandClaude Opus 4.7 02e2f3e8b5 refactor: remove dead code and narrow internal exports (knip sweep)
Knip-driven cleanup. All changes verified with tsc --noEmit, lint, and
build.

Removed (zero consumers):
- VERIFICATION_PROMPT constant + its barrel re-export
- createInitialOrchestratorPersistState factory
- transcriptWatcher singleton export
- createAnsiPatternFull / createAnsiPatternSimple factories
- TimerInfo interface + unused AiCheckResult/AiPlanCheckResult imports
  in respawn-controller.ts
- 35 unused Zod z.infer \`*Input\` types in schemas.ts
- Dead re-exports: SessionMode from session.ts, AuthSessionRecord from
  web/ports/index.ts, EnhancedPlanTask/CheckpointReview from
  ralph-tracker.ts, 7 unused entries in utils/index.ts
- 14 event/config interfaces that lived only as JSDoc hints (no TS type
  position usage): Session/Respawn/RalphLoop/RalphTracker/
  SessionManager/SessionAutoOps/Subagent/TaskQueue/TaskTracker/
  TranscriptWatcher/Image/OrchestratorLoop Events + RespawnPreset +
  SessionOutput

Narrowed to module scope (kept but no longer exported):
- buildPermissionArgs in session-cli-builder.ts
- 28 type/interface declarations used only within their own file:
  Ai{Idle,Plan}Check{Config,State}, BashToolParser{Events,Config},
  FileStream/CreateStream{Options,Result}, PlanSubagentEvent,
  SubagentCallback, RalphLoopConfig, RalphLoop{Events,Options},
  ActiveTimerInfo, DetectionStatus, ActionLogEntry, AutoOpsCallbacks,
  TunnelStatus, Timer/LRUMap/StaleExpirationMap Options, AuthState,
  SessionListenerDeps, SseStreamManagerDeps, and 8 more

Docs: CLAUDE.md advice for global-regex `lastIndex` now points to the
remaining `execPattern()` helper instead of the deleted factories.

Knip delta: unused files 42→0, unused exports 161→16, unused types 92→0.
The 16 remaining exports are a mobile-test helper toolkit intentionally
kept for upcoming tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:57:00 +02:00
arkonandClaude Opus 4.7 41300f0a34 chore: add knip config for dead-code detection
knip.json declares the real entry points (scripts, tests, Remotion roots)
and the devDeps invoked only as external CLIs (esbuild for build,
agent-browser/remotion via npx) so future scans surface only true
findings.

Add \`npm run knip\` as the canonical invocation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:56:31 +02:00
arkonandClaude Opus 4.7 adbc083426 chore: remove dead test and script files
Dead-code sweep via knip. All files below have zero importers and were
leftovers from the local-echo overlay exploration or duplicated by files
under test/mocks/.

Deleted:
- test/respawn-test-utils.ts (728-line duplicate of test/mocks/*)
- test/input-echo-test.mjs
- test/local-echo-*.mjs (7 files)
- test/manual/*.mjs (10 files; dir removed)
- scripts/remotion/components/TerminalScreen.tsx (unused Remotion demo)

Also cleaned stale JSDoc references to the removed
respawn-test-utils.ts in test/mocks/mock-session.ts and
test/mocks/test-helpers.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:56:22 +02:00
arkonandClaude Opus 4.7 3754bcd1aa docs: tighten CLAUDE.md — update counts and CI line
- CI line: include `check:lockfile` step now run in workflow
- Frontend: app.js is ~2.9K lines (was ~2.8K)
- Types: document `src/types/index.ts` as the barrel (14 domain files) plus `src/types.ts` root re-export
- API routes: updated handler counts (128 total; sessions 27, cases 9)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:55:56 +02:00
arkonandClaude Opus 4.7 f2f909ca9c docs: tighten CLAUDE.md — fix counts, remove footer redundancy
Fix stale counts (types 14 to 15, SSE events ~118 to ~120). Remove
redundant footer sections (References list duplicated inline citations;
Common Workflows bullets were self-evident or already stated; Tunnel and
Memory Leak Prevention folded into neighboring sections). 251 to 234 lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:14:16 +02:00
arkonandClaude Opus 4.7 1c3f2f6571 docs: tighten CLAUDE.md and archive 22 completed plan docs
CLAUDE.md: fix stale counts (types 14 to 15, SSE events ~118 to ~120),
remove redundant footer sections (References list duplicated inline citations;
Common Workflows bullets were self-evident or already stated; Tunnel/Memory
Leak Prevention folded into neighboring sections). 251 to 234 lines.

Move 22 completed implementation/phase/audit plans to docs/archive/ via
git mv so history is preserved. Living reference docs remain in docs/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:14:07 +02:00
arkonandClaude Opus 4.7 8da1bdf690 chore: prevent package-lock.json version drift permanently
Makes the drift that PR #70 caught impossible to repeat:

- `version-packages` script now runs `changeset version && npm install
  --package-lock-only && check-lockfile-sync`, so the lockfile is always
  regenerated and verified as part of consuming a changeset
- New `scripts/check-lockfile-sync.mjs` compares package.json#.version against
  package-lock.json's root and packages[""] version fields (npm ci does not
  enforce these, which is why the prior drift slipped through CI)
- CI now runs `npm run check:lockfile` on every push/PR — any future drift
  fails the build before merge
- COM workflow in CLAUDE.md collapsed back to a single release-bump step now
  that lockfile sync is automatic

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:01:51 +02:00
arkonandClaude Opus 4.7 a93325b312 fix: sync package-lock.json to 0.6.0 and document lockfile step in COM workflow
package.json has been at 0.6.0 since release, but package-lock.json stayed at
0.3.11 because `npm run version-packages` (changesets) does not regenerate the
lockfile. This left `npm ci` broken against the committed state.

Also adds step 4 (`npm install --package-lock-only`) to the COM workflow in
CLAUDE.md so future releases keep the lockfile in sync automatically.

Credit to @Matt2012 (#70) for catching the lockfile drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:00:10 +02:00
arkonandClaude Opus 4.7 2d03e4efc9 docs: update CLAUDE.md and README for clipboard API and tab shortcuts
Reflect changes from PRs #65–#68: bumped route/SSE counts, added
Ctrl+Shift+{/} (tab reorder), Alt+1-9 (tab switch), Ctrl+Shift+V
(voice), and POST /api/clipboard to the keyboard and API references.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 04:16:36 +02:00
87 changed files with 128 additions and 6601 deletions
+3
View File
@@ -22,6 +22,9 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Check package-lock.json version sync
run: npm run check:lockfile
- name: Type check
run: npm run typecheck
+9
View File
@@ -1,5 +1,14 @@
# aicodeman
## 0.6.1
### Patch Changes
- Internal cleanup and release hygiene:
- **Dead-code sweep via knip**: added `knip.json` for dead-code detection and ran a full sweep — removed unused test files, unused scripts, and narrowed internal module exports to the minimum surface area actually consumed.
- **Lockfile drift prevention**: `version-packages` now runs `npm install --package-lock-only` and verifies the lockfile is in sync via `scripts/check-lockfile-sync.mjs`; CI runs the same check on every push/PR so version drift fails the build instead of reaching production. Resolves the `package-lock.json` / `package.json` version mismatch that shipped in 0.6.0.
- **Docs tightening**: archived 22 completed plan docs from `docs/`, corrected file/handler counts in `CLAUDE.md`, documented the lockfile step in the COM workflow, and removed footer redundancy.
## 0.6.0
### Minor Changes
+25 -37
View File
@@ -10,7 +10,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
| Type check | `tsc --noEmit` |
| Lint | `npm run lint` (fix: `npm run lint:fix`) |
| Format | `npm run format` (check: `npm run format:check`) |
| Single test | `npx vitest run test/<file>.test.ts` |
| Single test | `npm test -- test/<file>.test.ts` (or `npx vitest run --config config/vitest.config.ts test/<file>.test.ts`) |
| Build | `npm run build` (esbuild via `scripts/build.mjs`, NOT tsc — `tsc --noEmit` is type-check only) |
| Production | `npm run build && systemctl --user restart codeman-web` |
## CRITICAL: Session Safety
@@ -48,11 +49,13 @@ When user says "COM":
CHANGESET
```
Replace `patch` with `minor` or `major` as needed. Include `"xterm-zerolag-input": patch` on a separate line if that package changed too.
3. **Consume the changeset**: `npm run version-packages` (bumps versions in `package.json` files and updates `CHANGELOG.md`)
3. **Consume the changeset**: `npm run version-packages` (auto-bumps `package.json` files, updates `CHANGELOG.md`, runs `npm install --package-lock-only`, and verifies lockfile sync via `scripts/check-lockfile-sync.mjs` — all in one command; never hand-edit `CHANGELOG.md` or `package-lock.json` versions)
4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
**Version**: 0.6.0 (must match `package.json`)
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
**Version**: 0.6.1 (must match `package.json`)
## Project Overview
@@ -78,7 +81,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| Production start | `npm run start` |
| Production logs | `journalctl --user -u codeman-web -f` |
**CI**: `.github/workflows/ci.yml` runs `typecheck`, `lint`, `format:check` on push to master/main and on PRs (Node 22). Tests excluded (they spawn tmux).
**CI**: `.github/workflows/ci.yml` runs `check:lockfile`, `typecheck`, `lint`, `format:check` on push to master/main and on PRs (Node 22). Tests excluded (they spawn tmux).
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`). ESLint flat config (`config/eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `scripts/remotion/**`.
@@ -87,7 +90,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
- **Single-line prompts only** — `writeViaMux()` sends text+Enter separately; multi-line breaks Ink
- **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly
- **Global regex `lastIndex`** — Use `createAnsiPatternFull/Simple()` factories, not shared `g`-flag patterns in loops
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
**Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files.
@@ -109,11 +112,11 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| **State** | `src/state-store.ts`, `src/run-summary.ts`, `src/session-lifecycle-log.ts` | |
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | |
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (14 route modules + barrel), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
| **Frontend** | `src/web/public/app.js` (~2.8K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 4 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`) + `sw.js` | |
| **Types** | `src/types/index.ts` → 14 domain files | See `@fileoverview` in index.ts |
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (15 route modules + barrel), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
| **Frontend** | `src/web/public/app.js` (~2.9K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 4 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`) + `sw.js` | |
| **Types** | `src/types/index.ts` (barrel) → 14 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in.
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`.
**Local package**: `packages/xterm-zerolag-input/` — local echo overlay for xterm.js; copy embedded in `app.js`.
@@ -150,7 +153,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
**Keyboard shortcuts**: Escape (close), Ctrl+? (help), Ctrl+W (kill), Ctrl+Tab (next), Alt+1-9 (switch tab), Shift+Enter (newline), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl/Cmd +/- (font).
**Keyboard shortcuts**: Escape (close), Ctrl+? (help), Ctrl+W (kill), Ctrl+Tab (next), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter (newline), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font).
### Security
@@ -167,11 +170,11 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
### SSE Event Registry
~117 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
~120 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
### API Routes
~124 handlers across 14 route files in `src/web/routes/`: system (36), sessions (25), orchestrator (10), ralph (9), plan (8), respawn (7), cases (7), files (5), mux (5), scheduled (4), push (4), teams (2), hooks (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
~128 handlers across 15 route files in `src/web/routes/`: system (36), sessions (27), orchestrator (10), cases (9), ralph (9), plan (8), respawn (7), files (5), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
## Adding Features
@@ -193,11 +196,13 @@ All in `~/.codeman/`: `state.json` (sessions, settings, respawn), `mux-sessions.
**CRITICAL: You are running inside a Codeman-managed tmux session.** Never run `npx vitest run` (full suite) — it spawns/kills tmux sessions and will crash your own session. Only run individual files:
```bash
npx vitest run test/<specific-file>.test.ts # Single file (SAFE)
npx vitest run -t "pattern" # By name (SAFE)
# npx vitest run # DANGEROUS — DON'T DO THIS
npm test -- test/<specific-file>.test.ts # Single file (SAFE, uses config/vitest.config.ts)
npm test -- -t "pattern" # By name (SAFE)
# npm test # DANGEROUS — runs full suite, DON'T DO THIS
```
Raw `npx vitest` skips `config/vitest.config.ts`; always use `npm test --` or pass `--config config/vitest.config.ts`.
**Config**: Vitest with `globals: true`, `fileParallelism: false`. Timeout 30s, teardown 60s.
**Safety**: `test/setup.ts` snapshots pre-existing tmux sessions and never kills them. Only `registerTestTmuxSession()` sessions get cleaned up.
@@ -206,10 +211,6 @@ npx vitest run -t "pattern" # By name (SAFE)
**Respawn tests**: Use `MockSession` from `test/respawn-test-utils.ts`. **Route tests**: `app.inject()` in `test/routes/`. **Mobile tests**: Playwright suite in `test/mobile/` (135 device profiles).
## Screenshots
Mobile screenshots in `~/.codeman/screenshots/`. API: `GET /api/screenshots`, `POST /api/screenshots`.
## Debugging
```bash
@@ -220,27 +221,14 @@ curl localhost:3000/api/subagents | jq # Background agents
cat ~/.codeman/state.json | jq # Persisted state
```
Mobile screenshots: `~/.codeman/screenshots/`, accessed via `GET/POST /api/screenshots`.
## Performance & Limits
Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 2MB, text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
## References
**Memory leaks (24+ hour sessions)**: use `CleanupManager`, clear Maps in `stop()`, guard async with `if (this.cleanup.isStopped) return`. Frontend: store handler refs, clean in `close*()`. Verify: `npm test -- test/memory-leak-prevention.test.ts`.
Deep-dive docs in `docs/`: `respawn-state-machine.md`, `ralph-wiggum-guide.md`, `claude-code-hooks-reference.md`, `terminal-anti-flicker.md`, `opencode-integration.md`, `qr-auth-plan.md`, `orchestrator-loop-architecture.md`, `browser-testing-guide.md`. Agent Teams: `docs/agent-teams/README.md`. SSE events: `src/web/sse-events.ts` + `constants.js`.
## Scripts & Tunnel
## Scripts
Key: `scripts/tmux-manager.sh` (safe tmux mgmt), `scripts/tunnel.sh` (tunnel start/stop/url). Production: `scripts/codeman-web.service`, `scripts/codeman-tunnel.service`.
## Memory Leak Prevention
24+ hour sessions: use `CleanupManager`, clear Maps in `stop()`, guard async with `if (this.cleanup.isStopped) return`. Frontend: store handler refs, clean in `close*()`. Verify: `npx vitest run test/memory-leak-prevention.test.ts`.
## Common Workflows
**Bug investigation**: Dev server → reproduce in browser → check terminal + `~/.codeman/state.json`.
**Respawn changes**: Read `docs/respawn-state-machine.md` first. Use `MockSession` from `test/respawn-test-utils.ts`.
## Tunnel
`./scripts/tunnel.sh start|stop|url`. **Always set `CODEMAN_PASSWORD`** before exposing via tunnel.
Key scripts: `scripts/tmux-manager.sh` (safe tmux mgmt), `scripts/tunnel.sh start|stop|url` (tunnel). Production services: `scripts/codeman-web.service`, `scripts/codeman-tunnel.service`. **Always set `CODEMAN_PASSWORD`** before exposing via tunnel.
+4
View File
@@ -401,9 +401,12 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
| `Ctrl+Enter` | Quick-start session |
| `Ctrl+W` | Close session |
| `Ctrl+Tab` | Next session |
| `Alt+1`–`Alt+9` | Switch to tab N |
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move active tab left / right |
| `Ctrl+K` | Kill all sessions |
| `Ctrl+L` | Clear terminal |
| `Ctrl+Shift+R` | Restore terminal size |
| `Ctrl+Shift+V` | Toggle voice input |
| `Ctrl/Cmd +/-` | Font size |
| `Escape` | Close panels |
@@ -446,6 +449,7 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
| `GET` | `/api/events` | SSE stream |
| `GET` | `/api/status` | Full app state |
| `POST` | `/api/hook-event` | Hook callbacks |
| `POST` | `/api/clipboard` | Push text to all connected browsers (`{text}`) |
| `GET` | `/api/sessions/:id/run-summary` | Timeline + stats |
---
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"entry": [
"scripts/*.mjs",
"scripts/*.js",
"scripts/watch-subagents.ts",
"scripts/remotion/Root.tsx",
"scripts/remotion/index.ts",
"test/**/*.test.ts",
"test/mobile/vitest.config.ts",
"test/**/*.mjs"
],
"project": ["src/**/*.{ts,tsx}", "scripts/**/*.{ts,tsx,mjs,js}", "test/**/*.{ts,mjs}"],
"ignoreExportsUsedInFile": true,
"ignoreDependencies": ["@remotion/cli", "@remotion/transitions", "esbuild", "agent-browser"]
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "0.3.11",
"version": "0.6.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "0.3.11",
"version": "0.6.1",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "0.6.0",
"version": "0.6.1",
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
@@ -25,7 +25,9 @@
"format:check": "prettier --check 'src/**/*.ts'",
"capture:subagents": "node scripts/capture-subagent-screenshots.mjs",
"changeset": "changeset",
"version-packages": "changeset version",
"version-packages": "changeset version && npm install --package-lock-only && node scripts/check-lockfile-sync.mjs",
"check:lockfile": "node scripts/check-lockfile-sync.mjs",
"knip": "npx --yes knip@latest",
"release": "changeset publish"
},
"workspaces": [
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env node
// Fails if package-lock.json's version fields don't match package.json.
// Changesets bumps package.json but NOT the lockfile — this catches that drift
// (the top-level `version` in lockfiles is metadata, so `npm ci` won't flag it).
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const pkg = JSON.parse(readFileSync(resolve(repoRoot, 'package.json'), 'utf8'));
const lock = JSON.parse(readFileSync(resolve(repoRoot, 'package-lock.json'), 'utf8'));
const expected = pkg.version;
const rootVersion = lock.version;
const selfVersion = lock.packages?.['']?.version;
const mismatches = [];
if (rootVersion !== expected) mismatches.push(` package-lock.json#.version = ${rootVersion} (expected ${expected})`);
if (selfVersion !== expected) mismatches.push(` package-lock.json#.packages[""].version = ${selfVersion} (expected ${expected})`);
if (mismatches.length > 0) {
console.error(`\nLockfile version drift detected (package.json is ${expected}):`);
console.error(mismatches.join('\n'));
console.error('\nFix: run `npm install --package-lock-only` and commit the updated package-lock.json.\n');
process.exit(1);
}
console.log(`Lockfile in sync with package.json (${expected}).`);
@@ -1,117 +0,0 @@
import React from 'react';
import { useCurrentFrame } from 'remotion';
import { colors, fonts } from '../lib/theme';
type OverlayChar = {
char: string;
confirmed: boolean;
};
type TerminalScreenProps = {
typed: string;
cursorVisible: boolean;
overlayChars?: OverlayChar[];
fontSize?: number;
};
/**
* Renders a terminal area styled exactly like the real Codeman xterm.js terminal.
* Background #0d0d0d, Fira Code font, block cursor, Claude Code prompt.
*/
export const TerminalScreen: React.FC<TerminalScreenProps> = ({
typed,
cursorVisible,
overlayChars,
fontSize = 28,
}) => {
const frame = useCurrentFrame();
// Block cursor — solid, no blink (matches Codeman's cursorBlink: false)
const cursorOn = cursorVisible;
// But add a subtle blink for video clarity so viewers notice it
const cursorOpacity = cursorOn ? (Math.floor(frame / 20) % 2 === 0 ? 0.9 : 0.6) : 0;
const lineHeight = Math.round(fontSize * 1.35);
const charWidth = fontSize * 0.6;
return (
<div
style={{
width: '100%',
height: '100%',
background: '#0d0d0d',
position: 'relative',
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
overflow: 'hidden',
}}
>
{/* Previous terminal output lines (fake history for realism) */}
<div
style={{
padding: '16px 20px',
fontSize: fontSize * 0.65,
lineHeight: `${Math.round(fontSize * 0.65 * 1.4)}px`,
color: '#495057',
}}
>
<div>
<span style={{ color: '#339af0' }}>❯</span>
<span style={{ color: '#495057' }}> claude --dangerously-skip-permissions</span>
</div>
<div style={{ color: '#3a3a3a', marginTop: 4 }}>
╭────────────────────────────────────╮
</div>
<div style={{ color: '#3a3a3a' }}>
│ Claude Code session active │
</div>
<div style={{ color: '#3a3a3a' }}>
╰────────────────────────────────────╯
</div>
</div>
{/* Active prompt line — this is where the typing happens */}
<div
style={{
padding: '8px 20px',
fontSize,
lineHeight: `${lineHeight}px`,
display: 'flex',
alignItems: 'baseline',
}}
>
{/* Prompt character */}
<span style={{ color: '#339af0', fontWeight: 700, marginRight: charWidth * 0.8 }}>❯</span>
{/* Typed text */}
{overlayChars ? (
// Zerolag mode: show overlay chars with confirmed/unconfirmed color
overlayChars.map((oc, i) => (
<span
key={i}
style={{
color: oc.confirmed ? '#e0e0e0' : '#7a7a7a',
letterSpacing: '0.5px',
}}
>
{oc.char}
</span>
))
) : (
<span style={{ color: '#e0e0e0', letterSpacing: '0.5px' }}>{typed}</span>
)}
{/* Block cursor */}
<span
style={{
display: 'inline-block',
width: charWidth,
height: lineHeight * 0.85,
background: `rgba(224, 224, 224, ${cursorOpacity})`,
verticalAlign: 'text-bottom',
marginLeft: 1,
}}
/>
</div>
</div>
);
};
+1 -1
View File
@@ -41,7 +41,7 @@ import {
// ========== Types ==========
export type AiIdleCheckConfig = AiCheckerConfigBase;
type AiIdleCheckConfig = AiCheckerConfigBase;
export type AiCheckVerdict = 'IDLE' | 'WORKING' | 'ERROR';
+2 -2
View File
@@ -40,13 +40,13 @@ import {
// ========== Types ==========
export type AiPlanCheckConfig = AiCheckerConfigBase;
type AiPlanCheckConfig = AiCheckerConfigBase;
export type AiPlanCheckVerdict = 'PLAN_MODE' | 'NOT_PLAN_MODE' | 'ERROR';
export type AiPlanCheckResult = AiCheckerResultBase<AiPlanCheckVerdict>;
export type AiPlanCheckState = AiCheckerStateBase<AiPlanCheckVerdict>;
type AiPlanCheckState = AiCheckerStateBase<AiPlanCheckVerdict>;
// ========== Constants ==========
+2 -2
View File
@@ -99,7 +99,7 @@ const LOG_FILE_MENTION_PATTERN = /([/~][^\s'"<>|;&\n]*(?:\.log|\.txt|\.out|\/log
/**
* Events emitted by BashToolParser.
*/
export interface BashToolParserEvents {
interface BashToolParserEvents {
/** New Bash tool with file paths started */
toolStart: [tool: ActiveBashTool];
/** Bash tool completed */
@@ -111,7 +111,7 @@ export interface BashToolParserEvents {
/**
* Configuration options for BashToolParser.
*/
export interface BashToolParserConfig {
interface BashToolParserConfig {
/** Session ID this parser belongs to */
sessionId: string;
/** Whether the parser is enabled (default: true) */
+3 -3
View File
@@ -48,7 +48,7 @@ const STREAM_INACTIVITY_TIMEOUT_MS = INACTIVITY_TIMEOUT_MS;
/**
* Represents an active file stream.
*/
export interface FileStream {
interface FileStream {
/** Unique stream identifier */
id: string;
/** Session this stream belongs to */
@@ -74,7 +74,7 @@ export interface FileStream {
/**
* Options for creating a file stream.
*/
export interface CreateStreamOptions {
interface CreateStreamOptions {
/** Session ID requesting the stream */
sessionId: string;
/** Path to the file to stream */
@@ -94,7 +94,7 @@ export interface CreateStreamOptions {
/**
* Result of creating a stream.
*/
export interface CreateStreamResult {
interface CreateStreamResult {
success: boolean;
streamId?: string;
error?: string;
-5
View File
@@ -17,11 +17,6 @@ import { KeyedDebouncer } from './utils/index.js';
// ========== Types ==========
export interface ImageWatcherEvents {
'image:detected': (event: ImageDetectedEvent) => void;
'image:error': (error: Error, sessionId?: string) => void;
}
// ========== Constants ==========
/** Supported image file extensions (lowercase) */
-15
View File
@@ -60,21 +60,6 @@ const POST_PHASE_DELAY_MS = 1000;
// Events
// ═══════════════════════════════════════════════════════════════
export interface OrchestratorLoopEvents {
stateChanged: (state: OrchestratorState, prevState: OrchestratorState) => void;
planProgress: (phase: string, detail: string) => void;
planReady: (plan: OrchestratorPlan) => void;
phaseStarted: (phase: OrchestratorPhase) => void;
phaseCompleted: (phase: OrchestratorPhase) => void;
phaseFailed: (phase: OrchestratorPhase, reason: string) => void;
taskAssigned: (task: OrchestratorTask, sessionId: string) => void;
taskCompleted: (task: OrchestratorTask) => void;
taskFailed: (task: OrchestratorTask, error: string) => void;
verificationResult: (phase: OrchestratorPhase, result: VerificationResult) => void;
completed: (stats: OrchestratorStats) => void;
error: (error: Error) => void;
}
// ═══════════════════════════════════════════════════════════════
// OrchestratorLoop
// ═══════════════════════════════════════════════════════════════
+2 -2
View File
@@ -68,7 +68,7 @@ export interface DetailedPlanResult {
export type ProgressCallback = (phase: string, detail: string) => void;
export interface PlanSubagentEvent {
interface PlanSubagentEvent {
type: 'started' | 'progress' | 'completed' | 'failed';
agentId: string;
agentType: 'research' | 'planner';
@@ -80,7 +80,7 @@ export interface PlanSubagentEvent {
error?: string;
}
export type SubagentCallback = (event: PlanSubagentEvent) => void;
type SubagentCallback = (event: PlanSubagentEvent) => void;
// ============================================================================
// JSON Repair Helper
+1 -7
View File
@@ -7,10 +7,4 @@
export { RESEARCH_AGENT_PROMPT } from './research-agent.js';
export { PLANNER_PROMPT } from './planner.js';
export {
PHASE_EXECUTION_PROMPT,
TEAM_LEAD_PROMPT,
VERIFICATION_PROMPT,
REPLAN_PROMPT,
SINGLE_TASK_PROMPT,
} from './orchestrator.js';
export { PHASE_EXECUTION_PROMPT, TEAM_LEAD_PROMPT, REPLAN_PROMPT, SINGLE_TASK_PROMPT } from './orchestrator.js';
-16
View File
@@ -57,22 +57,6 @@ Suggested teammate roles:
Each teammate should focus on their assigned task area. Monitor their progress.
When ALL tasks are complete and you've verified the results, output: <promise>{COMPLETION_PHRASE}</promise>`;
/**
* Verification prompt — asks Claude to verify phase completion.
*
* Placeholders:
* - {PHASE_NAME}: Phase name
* - {CRITERIA}: Numbered verification criteria
* - {PASS_PHRASE}: Phrase to output on success
* - {FAIL_PHRASE}: Phrase to output on failure
*/
export const VERIFICATION_PROMPT = `Review the work done in "{PHASE_NAME}". Check these criteria:
{CRITERIA}
If ALL criteria are met, respond with: {PASS_PHRASE}
If ANY criteria fail, respond with: {FAIL_PHRASE} and explain what failed.`;
/**
* Replan prompt — gives failure context and asks for recovery.
*
+1 -1
View File
@@ -24,7 +24,7 @@ const YAML_LINE_PATTERN = /^([a-zA-Z_-]+):\s*"?([^"\n]+)"?\s*$/gm;
/**
* Ralph Loop configuration from .claude/ralph-loop.local.md
*/
export interface RalphLoopConfig {
interface RalphLoopConfig {
enabled: boolean;
iteration: number;
maxIterations: number | null;
+1 -9
View File
@@ -34,19 +34,11 @@ import { RalphLoopStatus, getErrorMessage } from './types.js';
/**
* Events emitted by RalphLoop
*/
export interface RalphLoopEvents {
started: () => void;
stopped: () => void;
taskAssigned: (taskId: string, sessionId: string) => void;
taskCompleted: (taskId: string) => void;
taskFailed: (taskId: string, error: string) => void;
error: (error: Error) => void;
}
/**
* Configuration options for RalphLoop
*/
export interface RalphLoopOptions {
interface RalphLoopOptions {
/** How often to check for new tasks (default from config) */
pollIntervalMs?: number;
/** Minimum time to run before stopping (null = no minimum) */
-30
View File
@@ -19,7 +19,6 @@
* Key exports:
* - `RalphTracker` class — main tracker, extends EventEmitter
* - `RalphTrackerEvents` interface — typed event map
* - Re-exports: `EnhancedPlanTask`, `CheckpointReview` from ralph-plan-tracker
*
* Key methods: `processData(data)` — feed terminal output, `getState()`,
* `getTodos()`, `getCompletionHistory()`, `getPlanTasks()`, `reset()`
@@ -66,9 +65,6 @@ import { RalphStallDetector } from './ralph-stall-detector.js';
import { RalphStatusParser } from './ralph-status-parser.js';
import { STALE_DATA_MAX_AGE_MS, INACTIVITY_TIMEOUT_MS } from './config/server-timing.js';
// Re-export sub-module types for backward compatibility
export type { EnhancedPlanTask, CheckpointReview } from './ralph-plan-tracker.js';
// ========== Configuration Constants ==========
// Note: MAX_TODOS_PER_SESSION and MAX_LINE_BUFFER_SIZE are imported from config modules
@@ -390,32 +386,6 @@ const P2_PRIORITY_PATTERNS = [
* @event circuitBreakerUpdate - Fired when circuit breaker state changes
* @event exitGateMet - Fired when dual-condition exit gate is met
*/
export interface RalphTrackerEvents {
/** Emitted when loop state changes */
loopUpdate: (state: RalphTrackerState) => void;
/** Emitted when todo list is modified */
todoUpdate: (todos: RalphTodoItem[]) => void;
/** Emitted when completion phrase detected (loop finished) */
completionDetected: (phrase: string) => void;
/** Emitted when tracker auto-enables from disabled state */
enabled: () => void;
/** Emitted when a RALPH_STATUS block is parsed */
statusBlockDetected: (block: RalphStatusBlock) => void;
/** Emitted when circuit breaker state changes */
circuitBreakerUpdate: (status: CircuitBreakerStatus) => void;
/** Emitted when dual-condition exit gate is met (completion indicators >= 2 AND EXIT_SIGNAL: true) */
exitGateMet: (data: { completionIndicators: number; exitSignal: boolean }) => void;
/** Emitted when iteration count hasn't changed for an extended period (stall warning) */
iterationStallWarning: (data: { iteration: number; stallDurationMs: number }) => void;
/** Emitted when iteration count hasn't changed for critical period (stall critical) */
iterationStallCritical: (data: { iteration: number; stallDurationMs: number }) => void;
/** Emitted when a common/risky completion phrase is detected (P1-002) */
phraseValidationWarning: (data: {
phrase: string;
reason: 'common' | 'short' | 'numeric';
suggestedPhrase: string;
}) => void;
}
/**
* RalphTracker - Parses terminal output to detect Ralph Wiggum loops and todos
+6 -63
View File
@@ -27,7 +27,7 @@
* - `RespawnController` class — state machine, extends EventEmitter
* - `RespawnConfig` interface — all configuration options
* - `RespawnState` type — union of all state machine states
* - `DetectionStatus`, `ActiveTimerInfo`, `RespawnEvents` — status/event types
* - `DetectionStatus`, `ActiveTimerInfo` — status types
*
* Key methods: `start()`, `stop()`, `getStatus()`, `getConfig()`,
* `getDetectionStatus()`, `getActiveTimers()`, `getAggregateMetrics()`,
@@ -46,8 +46,8 @@
import { EventEmitter } from 'node:events';
import { randomUUID } from 'node:crypto';
import { Session } from './session.js';
import { AiIdleChecker, type AiCheckResult, type AiCheckState } from './ai-idle-checker.js';
import { AiPlanChecker, type AiPlanCheckResult } from './ai-plan-checker.js';
import { AiIdleChecker, type AiCheckState } from './ai-idle-checker.js';
import { AiPlanChecker } from './ai-plan-checker.js';
import type { TeamWatcher } from './team-watcher.js';
import { BufferAccumulator, ANSI_ESCAPE_PATTERN_SIMPLE, assertNever, CleanupManager } from './utils/index.js';
import { MAX_RESPAWN_BUFFER_SIZE, TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE } from './config/buffer-limits.js';
@@ -99,13 +99,13 @@ const PLAN_MODE_SELECTOR_PATTERN = /[❯>]\s*\d+\./;
* Each layer provides a confidence signal that Claude has finished working.
*/
/** Active timer info for UI display */
export interface ActiveTimerInfo {
interface ActiveTimerInfo {
name: string;
remainingMs: number;
totalMs: number;
}
export interface DetectionStatus {
interface DetectionStatus {
/** Layer 0: Stop hook received (highest priority - definitive signal) */
stopHookReceived: boolean;
/** Timestamp when Stop hook was received */
@@ -488,70 +488,13 @@ export interface RespawnConfig {
* @event error - Fired on errors
* @event log - Fired for debug logging
*/
/** Timer info for countdown display */
export interface TimerInfo {
name: string;
durationMs: number;
endsAt: number;
reason?: string;
}
/** Action log entry for detailed UI feedback */
export interface ActionLogEntry {
interface ActionLogEntry {
type: string;
detail: string;
timestamp: number;
}
export interface RespawnEvents {
/** State machine transition */
stateChanged: (state: RespawnState, prevState: RespawnState) => void;
/** New respawn cycle started */
respawnCycleStarted: (cycleNumber: number) => void;
/** Respawn cycle finished */
respawnCycleCompleted: (cycleNumber: number) => void;
/** Command sent to session */
stepSent: (step: string, input: string) => void;
/** Step completed (ready indicator detected) */
stepCompleted: (step: string) => void;
/** Detection status update for UI display */
detectionUpdate: (status: DetectionStatus) => void;
/** Auto-accept sent for plan mode approval */
autoAcceptSent: () => void;
/** AI idle check started */
aiCheckStarted: () => void;
/** AI idle check completed with verdict */
aiCheckCompleted: (result: AiCheckResult) => void;
/** AI idle check failed */
aiCheckFailed: (error: string) => void;
/** AI idle check cooldown state changed */
aiCheckCooldown: (active: boolean, endsAt: number | null) => void;
/** AI plan check started */
planCheckStarted: () => void;
/** AI plan check completed with verdict */
planCheckCompleted: (result: AiPlanCheckResult) => void;
/** AI plan check failed */
planCheckFailed: (error: string) => void;
/** Timer started for countdown display */
timerStarted: (timer: TimerInfo) => void;
/** Timer cancelled */
timerCancelled: (timerName: string, reason?: string) => void;
/** Timer completed */
timerCompleted: (timerName: string) => void;
/** Verbose action log for detailed UI feedback */
actionLog: (action: ActionLogEntry) => void;
/** Error occurred */
error: (error: Error) => void;
/** Debug log message */
log: (message: string) => void;
/** Stuck state warning emitted */
stuckStateWarning: (state: RespawnState, durationMs: number) => void;
/** Stuck state recovery triggered */
stuckStateRecovery: (state: RespawnState, durationMs: number, attempt: number) => void;
/** Respawn blocked by external signal */
respawnBlocked: (data: { reason: string; details: string }) => void;
}
/**
* Convert milliseconds to a non-negative whole number of seconds for countdown display.
* Rounds up so that e.g. 1200 ms shows as 2 s (never under-reports remaining time).
+1 -7
View File
@@ -93,7 +93,7 @@ const DEFAULT_AUTO_COMPACT_THRESHOLD = 110_000;
/**
* Callbacks required by SessionAutoOps to interact with the parent Session.
*/
export interface AutoOpsCallbacks {
interface AutoOpsCallbacks {
/** Send a command via the terminal multiplexer */
writeCommand: (command: string) => Promise<boolean>;
/** Check if Claude is currently working */
@@ -109,12 +109,6 @@ export interface AutoOpsCallbacks {
/**
* Events emitted by SessionAutoOps.
*/
export interface SessionAutoOpsEvents {
/** Auto-compact was triggered and the /compact command was sent */
autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => void;
/** Auto-clear was triggered and the /clear command was sent */
autoClear: (data: { tokens: number; threshold: number }) => void;
}
/**
* Manages auto-compact and auto-clear automation for a Session.
+1 -1
View File
@@ -15,7 +15,7 @@ import { getAugmentedPath } from './utils/index.js';
* Build Claude CLI permission flags based on the configured mode.
* Returns an array of args to pass to the CLI.
*/
export function buildPermissionArgs(claudeMode: ClaudeMode, allowedTools?: string): string[] {
function buildPermissionArgs(claudeMode: ClaudeMode, allowedTools?: string): string[] {
switch (claudeMode) {
case 'dangerously-skip-permissions':
return ['--dangerously-skip-permissions'];
-12
View File
@@ -30,18 +30,6 @@ import { SessionState } from './types.js';
/**
* Events emitted by SessionManager
*/
export interface SessionManagerEvents {
/** Fired when a new session starts successfully */
sessionStarted: (session: Session) => void;
/** Fired when a session stops (graceful or forced) */
sessionStopped: (sessionId: string) => void;
/** Fired when a session encounters an error */
sessionError: (sessionId: string, error: string) => void;
/** Fired when a session produces terminal output */
sessionOutput: (sessionId: string, output: string) => void;
/** Fired when a completion phrase is detected */
sessionCompletion: (sessionId: string, phrase: string) => void;
}
/**
* Manages multiple Claude sessions with lifecycle coordination.
-57
View File
@@ -154,63 +154,6 @@ export interface ClaudeMessage {
* Event signatures emitted by the Session class.
* Subscribe using `session.on('eventName', handler)`.
*/
export interface SessionEvents {
/** Processed text output (ANSI stripped) */
output: (data: string) => void;
/** Parsed JSON message from Claude CLI */
message: (msg: ClaudeMessage) => void;
/** Error output from the session */
error: (data: string) => void;
/** Session process exited */
exit: (code: number | null) => void;
/** One-shot prompt completed with result and cost */
completion: (result: string, cost: number) => void;
/** Raw terminal data (includes ANSI codes) */
terminal: (data: string) => void;
/** Signal to clear terminal display (after mux attach) */
clearTerminal: () => void;
/** New background task started */
taskCreated: (task: BackgroundTask) => void;
/** Background task status changed */
taskUpdated: (task: BackgroundTask) => void;
/** Background task finished successfully */
taskCompleted: (task: BackgroundTask) => void;
/** Background task failed with error */
taskFailed: (task: BackgroundTask, error: string) => void;
/** Auto-clear triggered due to token threshold */
autoClear: (data: { tokens: number; threshold: number }) => void;
/** Auto-compact triggered due to token threshold */
autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => void;
/** Ralph loop state changed */
ralphLoopUpdate: (state: RalphTrackerState) => void;
/** Ralph todo list updated */
ralphTodoUpdate: (todos: RalphTodoItem[]) => void;
/** Ralph completion phrase detected */
ralphCompletionDetected: (phrase: string) => void;
/** RALPH_STATUS block detected */
ralphStatusBlockDetected: (block: import('./types.js').RalphStatusBlock) => void;
/** Circuit breaker state changed */
ralphCircuitBreakerUpdate: (status: import('./types.js').CircuitBreakerStatus) => void;
/** Dual-condition exit gate met */
ralphExitGateMet: (data: { completionIndicators: number; exitSignal: boolean }) => void;
/** Bash tool with file paths started */
bashToolStart: (tool: ActiveBashTool) => void;
/** Bash tool completed */
bashToolEnd: (tool: ActiveBashTool) => void;
/** Active Bash tools list updated */
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
/** CLI info (version, model, account) updated */
cliInfoUpdated: (info: {
version: string | null;
model: string | null;
accountType: string | null;
latestVersion: string | null;
}) => void;
}
// SessionMode is imported from types.ts (single source of truth)
// Re-export for backwards compatibility with any external consumers
export type { SessionMode } from './types.js';
/**
* Core session class that wraps a PTY process running Claude CLI or a shell.
-11
View File
@@ -134,17 +134,6 @@ export interface SubagentToolResult {
isError: boolean; // Whether result is an error
}
export interface SubagentEvents {
'subagent:discovered': (info: SubagentInfo) => void;
'subagent:updated': (info: SubagentInfo) => void;
'subagent:tool_call': (data: SubagentToolCall) => void;
'subagent:tool_result': (data: SubagentToolResult) => void;
'subagent:progress': (data: SubagentProgress) => void;
'subagent:message': (data: SubagentMessage) => void;
'subagent:completed': (info: SubagentInfo) => void;
'subagent:error': (error: Error, agentId?: string) => void;
}
// ========== Constants ==========
const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude/projects');
-8
View File
@@ -17,14 +17,6 @@ import { getStore } from './state-store.js';
/**
* Events emitted by TaskQueue
*/
export interface TaskQueueEvents {
/** Fired when a task is added to the queue */
taskAdded: (task: Task) => void;
/** Fired when a task is removed from the queue */
taskRemoved: (taskId: string) => void;
/** Fired when a task's state changes */
taskUpdated: (task: Task) => void;
}
/**
* Priority queue for managing tasks with dependency support.
-10
View File
@@ -153,16 +153,6 @@ export interface BackgroundTask {
* @event taskCompleted - Task finished successfully
* @event taskFailed - Task finished with error
*/
export interface TaskTrackerEvents {
/** New task created */
taskCreated: (task: BackgroundTask) => void;
/** Task state updated */
taskUpdated: (task: BackgroundTask) => void;
/** Task completed successfully */
taskCompleted: (task: BackgroundTask) => void;
/** Task failed with error */
taskFailed: (task: BackgroundTask, error: string) => void;
}
/**
* TaskTracker - Detects and tracks background tasks in Claude Code sessions.
+2 -15
View File
@@ -20,7 +20,7 @@ import { createInterface } from 'node:readline';
/**
* Parsed transcript entry from the JSONL file
*/
export interface TranscriptEntry {
interface TranscriptEntry {
type: 'user' | 'assistant' | 'system' | 'result';
timestamp: string;
message?: {
@@ -35,7 +35,7 @@ export interface TranscriptEntry {
};
}
export interface TranscriptContentBlock {
interface TranscriptContentBlock {
type: 'text' | 'tool_use' | 'tool_result';
text?: string;
name?: string;
@@ -68,15 +68,6 @@ export interface TranscriptState {
lastUpdateAt: string | null;
}
export interface TranscriptWatcherEvents {
'transcript:update': (state: TranscriptState) => void;
'transcript:complete': (state: TranscriptState) => void;
'transcript:tool_start': (toolName: string) => void;
'transcript:tool_end': (toolName: string, isError: boolean) => void;
'transcript:error': (error: Error) => void;
'transcript:plan_mode': () => void;
}
// ========== Constants ==========
/** How often to check for new content when file watching fails */
@@ -435,7 +426,3 @@ export class TranscriptWatcher extends EventEmitter {
}
}
}
// ========== Singleton Export ==========
export const transcriptWatcher = new TranscriptWatcher();
+1 -1
View File
@@ -33,7 +33,7 @@ import { getErrorMessage } from './types.js';
// ========== Types ==========
export interface TunnelStatus {
interface TunnelStatus {
running: boolean;
url: string | null;
}
-15
View File
@@ -268,18 +268,3 @@ export function createInitialOrchestratorStats(): OrchestratorStats {
replanCount: 0,
};
}
/** Factory function for initial orchestrator persist state */
export function createInitialOrchestratorPersistState(
config: OrchestratorConfig = DEFAULT_ORCHESTRATOR_CONFIG
): OrchestratorPersistState {
return {
state: 'idle',
plan: null,
currentPhaseIndex: 0,
startedAt: null,
completedAt: null,
config,
stats: createInitialOrchestratorStats(),
};
}
-16
View File
@@ -230,22 +230,6 @@ export interface TimingHistory {
/**
* Named respawn configuration preset for quick setup
*/
export interface RespawnPreset {
/** Unique preset identifier */
id: string;
/** User-friendly preset name */
name: string;
/** Description of when to use this preset */
description?: string;
/** The respawn configuration (without enabled flag) */
config: Omit<RespawnConfig, 'enabled'>;
/** Duration in minutes (optional default) */
durationMinutes?: number;
/** Whether this is a built-in preset */
builtIn?: boolean;
/** Timestamp when created */
createdAt: number;
}
/**
* Persisted respawn configuration for mux sessions.
-8
View File
@@ -150,11 +150,3 @@ export interface SessionState {
/**
* Output captured from a session
*/
export interface SessionOutput {
/** Standard output content */
stdout: string;
/** Standard error content */
stderr: string;
/** Exit code of the process, null if still running */
exitCode: number | null;
}
+1 -1
View File
@@ -14,7 +14,7 @@ import type { Disposable, CleanupRegistration, CleanupResourceType } from '../ty
/**
* Options for setTimeout/setInterval with automatic cleanup.
*/
export interface TimerOptions {
interface TimerOptions {
/** Human-readable description for debugging */
description?: string;
}
+3 -6
View File
@@ -7,17 +7,14 @@
*/
export { BufferAccumulator } from './buffer-accumulator.js';
export { LRUMap, type LRUMapOptions } from './lru-map.js';
export { CleanupManager, type TimerOptions } from './cleanup-manager.js';
export { CleanupManager } from './cleanup-manager.js';
export { Debouncer, KeyedDebouncer } from './debouncer.js';
export { StaleExpirationMap, type StaleExpirationMapOptions } from './stale-expiration-map.js';
export { StaleExpirationMap } from './stale-expiration-map.js';
export {
ANSI_ESCAPE_PATTERN_FULL,
ANSI_ESCAPE_PATTERN_SIMPLE,
TOKEN_PATTERN,
SPINNER_PATTERN,
createAnsiPatternFull,
createAnsiPatternSimple,
stripAnsi,
SAFE_PATH_PATTERN,
execPattern,
@@ -27,4 +24,4 @@ export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-si
export { assertNever } from './type-safety.js';
export { wrapWithNice } from './nice-wrapper.js';
export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js';
export { resolveOpenCodeDir, isOpenCodeAvailable } from './opencode-cli-resolver.js';
export { resolveOpenCodeDir } from './opencode-cli-resolver.js';
+1 -1
View File
@@ -10,7 +10,7 @@
/**
* Configuration options for LRUMap.
*/
export interface LRUMapOptions<K, V> {
interface LRUMapOptions<K, V> {
/** Maximum number of entries before eviction */
maxSize: number;
/** Optional callback when an entry is evicted */
-18
View File
@@ -40,24 +40,6 @@ export const ANSI_ESCAPE_PATTERN_SIMPLE = /\x1b\[[0-9;]*[A-Za-z]/g;
*/
export const TOKEN_PATTERN = /(\d+(?:\.\d+)?)\s*([kKmM])?\s*tokens/;
/**
* Creates a fresh copy of ANSI_ESCAPE_PATTERN_FULL.
* Use when you need a pattern without shared lastIndex state.
*/
export function createAnsiPatternFull(): RegExp {
// eslint-disable-next-line no-control-regex
return /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g;
}
/**
* Creates a fresh copy of ANSI_ESCAPE_PATTERN_SIMPLE.
* Use when you need a pattern without shared lastIndex state.
*/
export function createAnsiPatternSimple(): RegExp {
// eslint-disable-next-line no-control-regex
return /\x1b\[[0-9;]*[A-Za-z]/g;
}
/**
* Strips ANSI escape codes from text using the comprehensive pattern.
* @param text - Text containing ANSI escape codes
+1 -1
View File
@@ -22,7 +22,7 @@ interface TimedEntry<V> {
/**
* Configuration options for StaleExpirationMap.
*/
export interface StaleExpirationMapOptions<K, V> {
interface StaleExpirationMapOptions<K, V> {
/** Time-to-live in milliseconds before entries expire */
ttlMs: number;
/** How often to run cleanup (default: ttlMs / 2) */
+1 -1
View File
@@ -23,7 +23,7 @@ import {
export const AUTH_COOKIE_NAME = 'codeman_session';
/** State returned from registerAuthMiddleware for cleanup in server stop() */
export interface AuthState {
interface AuthState {
authSessions: StaleExpirationMap<string, AuthSessionRecord> | null;
authFailures: StaleExpirationMap<string, number> | null;
qrAuthFailures: StaleExpirationMap<string, number> | null;
+1 -1
View File
@@ -11,5 +11,5 @@ export type { EventPort } from './event-port.js';
export type { RespawnPort } from './respawn-port.js';
export type { ConfigPort } from './config-port.js';
export type { InfraPort, ScheduledRun } from './infra-port.js';
export type { AuthPort, AuthSessionRecord } from './auth-port.js';
export type { AuthPort } from './auth-port.js';
export type { OrchestratorPort } from './orchestrator-port.js';
-42
View File
@@ -549,45 +549,6 @@ export const RalphLoopStartSchema = z.object({
.optional(),
});
// ========== Inferred Types ==========
export type CreateSessionInput = z.infer<typeof CreateSessionSchema>;
export type RunPromptInput = z.infer<typeof RunPromptSchema>;
export type ResizeInput = z.infer<typeof ResizeSchema>;
export type CreateCaseInput = z.infer<typeof CreateCaseSchema>;
export type QuickStartInput = z.infer<typeof QuickStartSchema>;
export type HookEventInput = z.infer<typeof HookEventSchema>;
export type RespawnConfigInput = z.infer<typeof RespawnConfigSchema>;
export type ConfigUpdateInput = z.infer<typeof ConfigUpdateSchema>;
export type SettingsUpdateInput = z.infer<typeof SettingsUpdateSchema>;
export type SessionInputWithLimitInput = z.infer<typeof SessionInputWithLimitSchema>;
export type SessionNameInput = z.infer<typeof SessionNameSchema>;
export type SessionColorInput = z.infer<typeof SessionColorSchema>;
export type RalphConfigInput = z.infer<typeof RalphConfigSchema>;
export type FixPlanImportInput = z.infer<typeof FixPlanImportSchema>;
export type RalphPromptWriteInput = z.infer<typeof RalphPromptWriteSchema>;
export type AutoClearInput = z.infer<typeof AutoClearSchema>;
export type AutoCompactInput = z.infer<typeof AutoCompactSchema>;
export type ImageWatcherInput = z.infer<typeof ImageWatcherSchema>;
export type FlickerFilterInput = z.infer<typeof FlickerFilterSchema>;
export type QuickRunInput = z.infer<typeof QuickRunSchema>;
export type ScheduledRunInput = z.infer<typeof ScheduledRunSchema>;
export type LinkCaseInput = z.infer<typeof LinkCaseSchema>;
export type GeneratePlanInput = z.infer<typeof GeneratePlanSchema>;
export type GeneratePlanDetailedInput = z.infer<typeof GeneratePlanDetailedSchema>;
export type CancelPlanInput = z.infer<typeof CancelPlanSchema>;
export type PlanTaskUpdateInput = z.infer<typeof PlanTaskUpdateSchema>;
export type PlanTaskAddInput = z.infer<typeof PlanTaskAddSchema>;
export type CpuLimitInput = z.infer<typeof CpuLimitSchema>;
export type ModelConfigUpdateInput = z.infer<typeof ModelConfigUpdateSchema>;
export type SubagentWindowStatesInput = z.infer<typeof SubagentWindowStatesSchema>;
export type SubagentParentMapInput = z.infer<typeof SubagentParentMapSchema>;
export type InteractiveRespawnInput = z.infer<typeof InteractiveRespawnSchema>;
export type RespawnEnableInput = z.infer<typeof RespawnEnableSchema>;
export type PushSubscribeInput = z.infer<typeof PushSubscribeSchema>;
export type PushPreferencesUpdateInput = z.infer<typeof PushPreferencesUpdateSchema>;
export type RalphLoopStartInput = z.infer<typeof RalphLoopStartSchema>;
// ========== Orchestrator Loop ==========
/** POST /api/orchestrator/start */
@@ -612,6 +573,3 @@ export const OrchestratorStartSchema = z.object({
export const OrchestratorRejectSchema = z.object({
feedback: z.string().min(1).max(10000),
});
export type OrchestratorStartInput = z.infer<typeof OrchestratorStartSchema>;
export type OrchestratorRejectInput = z.infer<typeof OrchestratorRejectSchema>;
+1 -1
View File
@@ -58,7 +58,7 @@ export interface SessionListenerRefs {
}
/** Dependencies injected by WebServer — keeps listener creation decoupled from server internals. */
export interface SessionListenerDeps {
interface SessionListenerDeps {
broadcast(event: string, data: unknown): void;
batchTerminalData(sessionId: string, data: string): void;
batchTaskUpdate(sessionId: string, task: BackgroundTask): void;
+1 -1
View File
@@ -35,7 +35,7 @@ import {
const SSE_PADDING = ':' + 'p'.repeat(SSE_PADDING_SIZE) + '\n';
/** Dependencies injected by WebServer — keeps SseStreamManager decoupled from session/respawn state. */
export interface SseStreamManagerDeps {
interface SseStreamManagerDeps {
/** Get session state with respawn info for session:updated broadcasts */
getSessionStateWithRespawn(sessionId: string): unknown;
}
-241
View File
@@ -1,241 +0,0 @@
/**
* Input echo test: Verifies that typing in the web UI doesn't produce
* "visible below" artifacts (text mirrored onto Ink's status bar rows).
*
* Test approach:
* 1. Creates a SHELL session (bash, not Claude) to avoid Ink complexity
* 2. Types various input patterns and captures terminal state
* 3. Verifies echo appears at correct cursor position, not duplicated
*
* Usage: node test/input-echo-test.mjs [--live]
* --live Use existing sessions instead of creating new ones
*/
import { chromium } from 'playwright';
const PORT = 3000;
const BASE = `http://localhost:${PORT}`;
async function createShellSession() {
const resp = await fetch(`${BASE}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'shell', name: 'input-test' })
});
if (!resp.ok) throw new Error(`Failed to create session: ${resp.status}`);
const data = await resp.json();
return data.session?.id || data.id;
}
async function deleteSession(id) {
await fetch(`${BASE}/api/sessions/${id}`, { method: 'DELETE' }).catch(() => {});
}
async function waitForSessionReady(sessionId, timeoutMs = 10000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const resp = await fetch(`${BASE}/api/sessions/${sessionId}`);
if (resp.ok) {
const data = await resp.json();
if (data.status === 'idle' || data.status === 'busy') return true;
}
await new Promise(r => setTimeout(r, 200));
}
return false;
}
async function getTerminalContent(page) {
return page.evaluate(() => {
const app = window.app;
if (!app?.terminal) return null;
const buf = app.terminal.buffer.active;
const lines = [];
for (let i = 0; i <= buf.baseY + buf.viewportY + app.terminal.rows; i++) {
const line = buf.getLine(i);
if (line) lines.push(line.translateToString(true));
}
return {
lines,
cursorX: buf.cursorX,
cursorY: buf.cursorY,
rows: app.terminal.rows,
cols: app.terminal.cols,
};
});
}
async function main() {
const isLive = process.argv.includes('--live');
let sessionId = null;
console.log('=== Input Echo Test ===\n');
// Create a shell session for testing
if (!isLive) {
console.log('Creating shell session...');
sessionId = await createShellSession();
console.log(`Session created: ${sessionId}`);
const ready = await waitForSessionReady(sessionId);
if (!ready) {
console.error('Session failed to become ready');
await deleteSession(sessionId);
process.exit(1);
}
console.log('Session ready\n');
}
const browser = await chromium.launch({
headless: true,
args: ['--window-size=1280,800']
});
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const page = await context.newPage();
// Collect console output for debugging
const consoleLogs = [];
page.on('console', msg => consoleLogs.push(`[${msg.type()}] ${msg.text()}`));
try {
console.log('Opening Codeman...');
await page.goto(BASE);
await page.waitForSelector('.xterm-screen', { timeout: 10000 });
await page.waitForTimeout(2000);
// Switch to our test session if not live
if (sessionId) {
// Click the test session tab
const tabClicked = await page.evaluate((id) => {
const tabs = document.querySelectorAll('.session-tab');
for (const tab of tabs) {
if (tab.dataset.sessionId === id) {
tab.click();
return true;
}
}
return false;
}, sessionId);
if (tabClicked) {
await page.waitForTimeout(1000);
console.log('Switched to test session');
}
}
// Focus the terminal
await page.click('.xterm-screen');
await page.waitForTimeout(500);
// Take before screenshot
await page.screenshot({ path: '/tmp/input-test-before.png' });
console.log('Screenshot: /tmp/input-test-before.png (before typing)');
// Get terminal state before typing
const beforeState = await getTerminalContent(page);
const beforeCursorY = beforeState?.cursorY ?? -1;
console.log(`Before: cursor at row ${beforeCursorY}, col ${beforeState?.cursorX}`);
// TEST 1: Type a short string
console.log('\n--- Test 1: Short string ---');
const testStr = 'echo hello-world-test-123';
await page.keyboard.type(testStr, { delay: 30 });
await page.waitForTimeout(600); // Wait for PTY echo round-trip
const afterShort = await getTerminalContent(page);
await page.screenshot({ path: '/tmp/input-test-short.png' });
console.log('Screenshot: /tmp/input-test-short.png');
// Check: cursor should still be on same row (or +1 for wrap)
// and text should appear at/near the cursor position
const cursorRowAfter = afterShort?.cursorY ?? -1;
console.log(`After: cursor at row ${cursorRowAfter}, col ${afterShort?.cursorX}`);
// The echo should be on the cursor's row, not below
const echoLine = afterShort?.lines?.[afterShort.cursorY + (afterShort.lines.length - afterShort.rows)] ?? '';
console.log(`Echo line: "${echoLine.trim()}"`);
if (echoLine.includes(testStr)) {
console.log('PASS: Echo found at cursor row');
} else {
console.log('INFO: Echo may be on a different row (check screenshots)');
}
// TEST 2: Press Enter and check response
console.log('\n--- Test 2: Enter key ---');
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
const afterEnter = await getTerminalContent(page);
await page.screenshot({ path: '/tmp/input-test-enter.png' });
console.log('Screenshot: /tmp/input-test-enter.png');
// Check for "hello-world-test-123" in the output
const allText = afterEnter?.lines?.join('\n') ?? '';
if (allText.includes('hello-world-test-123')) {
console.log('PASS: Command output visible');
} else {
console.log('WARN: Command output not found');
}
// TEST 3: Type a LONG string that approaches right edge
console.log('\n--- Test 3: Long string (near right edge) ---');
const longStr = 'echo ' + 'A'.repeat(afterShort?.cols ? afterShort.cols - 10 : 150);
await page.keyboard.type(longStr, { delay: 10 });
await page.waitForTimeout(800);
const afterLong = await getTerminalContent(page);
await page.screenshot({ path: '/tmp/input-test-long.png' });
console.log('Screenshot: /tmp/input-test-long.png');
console.log(`After long: cursor at row ${afterLong?.cursorY}, col ${afterLong?.cursorX}`);
// Check that no text leaked below the cursor row
const cursorAbsRow = (afterLong?.lines?.length ?? 0) - (afterLong?.rows ?? 0) + (afterLong?.cursorY ?? 0);
const rowsBelow = afterLong?.lines?.slice(cursorAbsRow + 1) ?? [];
const leakedText = rowsBelow.filter(l => l.trim().length > 0 && l.includes('AAA'));
if (leakedText.length === 0) {
console.log('PASS: No text leaked below cursor row');
} else {
console.log(`FAIL: Text leaked below cursor: ${leakedText.map(l => `"${l.trim()}"`).join(', ')}`);
}
// TEST 4: Rapid typing (paste-like)
console.log('\n--- Test 4: Rapid input (paste simulation) ---');
await page.keyboard.press('Enter');
await page.waitForTimeout(500);
// Type rapidly (no delay between chars)
await page.keyboard.type('echo rapid-test-no-delay-abcdefghij', { delay: 0 });
await page.waitForTimeout(800);
await page.screenshot({ path: '/tmp/input-test-rapid.png' });
console.log('Screenshot: /tmp/input-test-rapid.png');
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
const afterRapid = await getTerminalContent(page);
const rapidText = afterRapid?.lines?.join('\n') ?? '';
if (rapidText.includes('rapid-test-no-delay-abcdefghij')) {
console.log('PASS: Rapid input command output visible');
} else {
console.log('WARN: Rapid input output not found (check screenshots)');
}
// Final screenshot
await page.screenshot({ path: '/tmp/input-test-final.png' });
console.log('\nScreenshot: /tmp/input-test-final.png (final state)');
// Summary
console.log('\n=== Test Complete ===');
console.log('Check screenshots in /tmp/input-test-*.png');
console.log('Key check: no text should appear below the active cursor row');
} finally {
await browser.close();
if (sessionId && !isLive) {
console.log(`\nCleaning up session ${sessionId}...`);
await deleteSession(sessionId);
}
}
}
main().catch(err => {
console.error('Test failed:', err);
process.exit(1);
});
-590
View File
@@ -1,590 +0,0 @@
/**
* Local Echo Overlay — Basic operation tests.
*
* Tests the most fundamental user scenarios:
* 1. Type → switch tab → come back → backspace works
* 2. Type → switch tab → come back → space doesn't erase text
* 3. Type → switch tab → come back → backspace + new text works
* 4. Overlay position stays consistent across renders
* 5. Overlay doesn't shift left during Ink redraws
* 6. Type → switch → back → rapid backspace+retype
* 7. Overlay covers flushed canvas text properly
*
* Usage: node test/local-echo-basic-test.mjs
*/
import { chromium } from 'playwright';
const PORT = 3000;
const BASE = `http://localhost:${PORT}`;
const SCREENSHOT_DIR = '/tmp/local-echo-basic';
async function createSession(name) {
const resp = await fetch(`${BASE}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'claude', name })
});
if (!resp.ok) throw new Error(`Failed to create '${name}': ${resp.status}`);
const data = await resp.json();
return data.session?.id || data.id;
}
async function deleteSession(id) {
await fetch(`${BASE}/api/sessions/${id}`, { method: 'DELETE' }).catch(() => {});
}
async function waitForReady(id, ms = 15000) {
const start = Date.now();
while (Date.now() - start < ms) {
const r = await fetch(`${BASE}/api/sessions/${id}`);
if (r.ok) { const d = await r.json(); if (d.status === 'idle' || d.status === 'busy') return true; }
await new Promise(r => setTimeout(r, 300));
}
return false;
}
async function getState(page) {
return page.evaluate(() => {
const app = window.app;
if (!app) return null;
const ov = app._localEchoOverlay;
const buf = app.terminal?.buffer?.active;
return {
visible: ov?.overlay?.style.display !== 'none',
text: ov?.pendingText || '',
flushed: ov?._flushedOffset || 0,
flushedMap: app._flushedOffsets ? Object.fromEntries(app._flushedOffsets) : {},
enabled: app._localEchoEnabled,
activeId: app.activeSessionId,
// Overlay position and content
overlayLeft: ov?.overlay?.style.left,
overlayTop: ov?.overlay?.style.top,
children: (() => {
if (!ov?.overlay) return [];
return Array.from(ov.overlay.children).map(c => ({
text: Array.from(c.querySelectorAll('span')).map(s => s.textContent).join(''),
left: c.style.left,
top: c.style.top,
width: c.style.width,
bgColor: c.style.backgroundColor,
spanCount: c.querySelectorAll('span').length,
}));
})(),
// Prompt line from buffer
promptLine: (() => {
if (!buf) return null;
for (let row = app.terminal.rows - 1; row >= 0; row--) {
const line = buf.getLine(buf.viewportY + row);
if (!line) continue;
const text = line.translateToString(true);
if (text.includes('\u276f')) return { text, row, col: text.lastIndexOf('\u276f') };
}
return null;
})(),
};
});
}
async function focus(page) {
await page.evaluate(() => document.querySelector('.xterm-helper-textarea')?.focus());
await page.waitForTimeout(200);
}
async function switchTab(page, id) {
await page.evaluate((id) => document.querySelector(`.session-tab[data-id="${id}"]`)?.click(), id);
await page.waitForTimeout(1500);
}
async function waitForPrompt(page, timeoutMs = 10000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const state = await getState(page);
if (state?.promptLine) return true;
await page.waitForTimeout(200);
}
return false;
}
async function sc(page, name) {
const path = `${SCREENSHOT_DIR}/${name}.png`;
await page.screenshot({ path });
console.log(` 📸 ${path}`);
}
const R = [];
const pass = (n) => { R.push({n, s:'PASS'}); console.log(` ✅ ${n}`); };
const fail = (n, d) => { R.push({n, s:'FAIL', d}); console.log(` ❌ ${n} — ${d}`); };
async function main() {
const { mkdirSync } = await import('fs');
try { mkdirSync(SCREENSHOT_DIR, { recursive: true }); } catch {}
console.log('=== Local Echo — Basic Operation Tests ===\n');
const sA = await createSession('basic-A');
const sB = await createSession('basic-B');
console.log(` A: ${sA}\n B: ${sB}`);
if (!await waitForReady(sA) || !await waitForReady(sB)) {
await deleteSession(sA); await deleteSession(sB);
throw new Error('Sessions not ready');
}
console.log(' Ready\n');
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({
viewport: { width: 390, height: 844 },
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)',
isMobile: true, hasTouch: true,
});
const page = await ctx.newPage();
// Capture JS errors
const jsErrors = [];
page.on('pageerror', err => jsErrors.push(err.message));
try {
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.xterm-screen', { timeout: 10000 });
await page.waitForTimeout(2000);
// Enable local echo
await page.evaluate(() => {
const s = JSON.parse(localStorage.getItem('codeman-app-settings') || '{}');
s.localEchoEnabled = true;
localStorage.setItem('codeman-app-settings', JSON.stringify(s));
window.app?._updateLocalEchoState?.();
});
// Select tab A, wait for prompt
await switchTab(page, sA);
await focus(page);
if (!await waitForPrompt(page)) throw new Error('No prompt on A');
// ══════════════════════════════════════════════════════════════
// TEST 1: Type → switch → back → IMMEDIATE backspace
// ══════════════════════════════════════════════════════════════
console.log('--- 1. Type → switch → back → immediate backspace ---');
await page.keyboard.type('hello world', { delay: 30 });
await page.waitForTimeout(300);
let st = await getState(page);
console.log(` Typed: text="${st?.text}"`);
if (st?.text === 'hello world') pass('1a: Text buffered');
else fail('1a: Text buffered', `"${st?.text}"`);
await sc(page, '01-typed');
// Switch to B and back
await switchTab(page, sB);
st = await getState(page);
console.log(` On B: text="${st?.text}", flushedMap=${JSON.stringify(st?.flushedMap)}`);
await switchTab(page, sA);
await focus(page);
// Check state BEFORE doing anything — flushed offset should be restored
st = await getState(page);
console.log(` Back on A: text="${st?.text}", flushed=${st?.flushed}`);
if (st?.flushed === 11) pass('1b: Flushed offset restored');
else fail('1b: Flushed offset', `expected 11, got ${st?.flushed}`);
// IMMEDIATELY backspace (don't wait for buffer load)
await page.keyboard.press('Backspace');
await page.waitForTimeout(300);
st = await getState(page);
console.log(` After 1 backspace: text="${st?.text}", flushed=${st?.flushed}`);
if (st?.flushed === 10) pass('1c: Backspace decremented flushed');
else fail('1c: Backspace', `expected flushed=10, got ${st?.flushed}`);
await sc(page, '02-after-backspace');
// Backspace 5 more times
for (let i = 0; i < 5; i++) {
await page.keyboard.press('Backspace');
await page.waitForTimeout(50);
}
await page.waitForTimeout(300);
st = await getState(page);
console.log(` After 6 total backspaces: flushed=${st?.flushed}`);
if (st?.flushed === 5) pass('1d: Multiple backspaces work');
else fail('1d: Multiple backspaces', `expected flushed=5, got ${st?.flushed}`);
// Clean up
await page.keyboard.press('Control+c');
await page.waitForTimeout(500);
// ══════════════════════════════════════════════════════════════
// TEST 2: Type → switch → back → IMMEDIATE space (don't erase)
// ══════════════════════════════════════════════════════════════
console.log('\n--- 2. Type → switch → back → immediate space ---');
await focus(page);
await waitForPrompt(page);
await page.keyboard.type('test input', { delay: 30 });
await page.waitForTimeout(300);
// Switch and return
await switchTab(page, sB);
await switchTab(page, sA);
await focus(page);
// Check flushed offset is restored
st = await getState(page);
console.log(` Back on A: flushed=${st?.flushed}`);
// Type a space immediately
await page.keyboard.type(' ', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
console.log(` After space: text="${st?.text}", flushed=${st?.flushed}`);
console.log(` Children: ${JSON.stringify(st?.children?.map(c => c.text))}`);
await sc(page, '03-after-space');
// The overlay should show the flushed text + space
if (st?.text === ' ' && st?.flushed > 0) pass('2a: Space typed with flushed offset');
else fail('2a: Space', `text="${st?.text}", flushed=${st?.flushed}`);
// The first child should contain the flushed text + space (not just space)
const child = st?.children?.[0];
if (child && child.text && child.text.length > 1) {
pass('2b: Overlay shows flushed text + space (not just space)');
console.log(` Overlay text: "${child.text}"`);
} else {
fail('2b: Overlay content', `child text="${child?.text}"`);
}
// Clean up
await page.keyboard.press('Control+c');
await page.waitForTimeout(500);
// ══════════════════════════════════════════════════════════════
// TEST 3: Type → switch → back → backspace some + type new text
// ══════════════════════════════════════════════════════════════
console.log('\n--- 3. Type → switch → back → backspace + retype ---');
await focus(page);
await waitForPrompt(page);
await page.keyboard.type('old text here', { delay: 30 });
await page.waitForTimeout(300);
// Switch and return
await switchTab(page, sB);
await switchTab(page, sA);
await focus(page);
await page.waitForTimeout(500); // Small wait for buffer
// Backspace 4 chars ("here")
for (let i = 0; i < 4; i++) {
await page.keyboard.press('Backspace');
await page.waitForTimeout(50);
}
await page.waitForTimeout(300);
st = await getState(page);
console.log(` After 4 backspaces: flushed=${st?.flushed}, text="${st?.text}"`);
if (st?.flushed === 9) pass('3a: Backspace 4 leaves flushed=9');
else fail('3a: Backspace count', `expected flushed=9, got ${st?.flushed}`);
// Now type new text
await page.keyboard.type('new stuff', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
console.log(` After retype: text="${st?.text}", flushed=${st?.flushed}`);
console.log(` Overlay: ${JSON.stringify(st?.children?.map(c => c.text))}`);
await sc(page, '04-backspace-retype');
if (st?.text === 'new stuff') pass('3b: New text after backspace correct');
else fail('3b: New text', `"${st?.text}"`);
// Overlay should show flushed chars + new text
const overlayText3 = st?.children?.[0]?.text || '';
console.log(` Full overlay text: "${overlayText3}"`);
if (overlayText3.endsWith('new stuff') && overlayText3.length > 'new stuff'.length) {
pass('3c: Overlay combines flushed + new text');
} else {
fail('3c: Combined overlay', `"${overlayText3}"`);
}
await page.keyboard.press('Control+c');
await page.waitForTimeout(500);
// ══════════════════════════════════════════════════════════════
// TEST 4: Overlay position consistency
// ══════════════════════════════════════════════════════════════
console.log('\n--- 4. Overlay position consistency ---');
await focus(page);
await waitForPrompt(page);
// Type and record position
await page.keyboard.type('position test', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
const pos1 = {
overlayLeft: st?.overlayLeft,
overlayTop: st?.overlayTop,
childLeft: st?.children?.[0]?.left,
promptCol: st?.promptLine?.col,
};
console.log(` Initial position: overlay=(${pos1.overlayLeft}, ${pos1.overlayTop}), child left=${pos1.childLeft}, prompt col=${pos1.promptCol}`);
await sc(page, '05-position-1');
// Type more text — position should stay stable (left doesn't change)
await page.keyboard.type('!', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
const pos2 = {
overlayLeft: st?.overlayLeft,
overlayTop: st?.overlayTop,
childLeft: st?.children?.[0]?.left,
promptCol: st?.promptLine?.col,
};
console.log(` After more text: overlay=(${pos2.overlayLeft}, ${pos2.overlayTop}), child left=${pos2.childLeft}, prompt col=${pos2.promptCol}`);
if (pos1.overlayLeft === pos2.overlayLeft && pos1.overlayTop === pos2.overlayTop) {
pass('4a: Overlay container position stable');
} else {
fail('4a: Overlay shifted', `${pos1.overlayLeft},${pos1.overlayTop} → ${pos2.overlayLeft},${pos2.overlayTop}`);
}
if (pos1.childLeft === pos2.childLeft) {
pass('4b: Child left position stable');
} else {
fail('4b: Child left shifted', `${pos1.childLeft} → ${pos2.childLeft}`);
}
await page.keyboard.press('Control+c');
await page.waitForTimeout(500);
// ══════════════════════════════════════════════════════════════
// TEST 5: Switch → return → type → position matches prompt
// ══════════════════════════════════════════════════════════════
console.log('\n--- 5. Position after tab switch ---');
await focus(page);
await waitForPrompt(page);
// Record prompt position
st = await getState(page);
const promptCol5 = st?.promptLine?.col;
console.log(` Prompt col before: ${promptCol5}`);
await page.keyboard.type('before switch', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
const beforeLeft = st?.children?.[0]?.left;
console.log(` Overlay left before switch: ${beforeLeft}`);
await sc(page, '06-before-switch');
// Switch and return
await switchTab(page, sB);
await switchTab(page, sA);
await focus(page);
await page.waitForTimeout(500);
// Type something
await page.keyboard.type(' more', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
const afterLeft = st?.children?.[0]?.left;
const promptCol5After = st?.promptLine?.col;
console.log(` Prompt col after: ${promptCol5After}`);
console.log(` Overlay left after switch+type: ${afterLeft}`);
await sc(page, '07-after-switch-type');
if (beforeLeft === afterLeft) {
pass('5a: Overlay position stable after tab switch');
} else {
fail('5a: Position shifted after switch', `${beforeLeft} → ${afterLeft}`);
}
await page.keyboard.press('Control+c');
await page.waitForTimeout(500);
// ══════════════════════════════════════════════════════════════
// TEST 6: Rapid backspace-and-retype after return
// ══════════════════════════════════════════════════════════════
console.log('\n--- 6. Rapid backspace-and-retype ---');
await focus(page);
await waitForPrompt(page);
await page.keyboard.type('rapid test', { delay: 20 });
await page.waitForTimeout(200);
// Switch and return
await switchTab(page, sB);
await switchTab(page, sA);
await focus(page);
// Rapidly: backspace 4, type "done"
for (let i = 0; i < 4; i++) await page.keyboard.press('Backspace');
await page.keyboard.type('done', { delay: 10 });
await page.waitForTimeout(300);
st = await getState(page);
console.log(` After rapid edit: text="${st?.text}", flushed=${st?.flushed}`);
await sc(page, '08-rapid-edit');
if (st?.text === 'done') pass('6a: Rapid backspace+retype text correct');
else fail('6a: Rapid edit text', `"${st?.text}"`);
if (st?.flushed === 6) pass('6b: Flushed offset correct after rapid backspace');
else fail('6b: Flushed offset', `expected 6, got ${st?.flushed}`);
await page.keyboard.press('Control+c');
await page.waitForTimeout(500);
// ══════════════════════════════════════════════════════════════
// TEST 7: Overlay background covers canvas text
// ══════════════════════════════════════════════════════════════
console.log('\n--- 7. Overlay covers canvas text ---');
await focus(page);
await waitForPrompt(page);
await page.keyboard.type('canvas cover', { delay: 30 });
await page.waitForTimeout(200);
// Flush by switching tabs
await switchTab(page, sB);
await switchTab(page, sA);
await focus(page);
await page.waitForTimeout(500); // Wait for buffer
// Type a space — overlay should render flushed text + space
await page.keyboard.type('X', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
const overlayChild = st?.children?.[0];
console.log(` Overlay: visible=${st?.visible}, text="${overlayChild?.text}", width=${overlayChild?.width}, bg=${overlayChild?.bgColor}`);
await sc(page, '09-canvas-cover');
if (st?.visible && overlayChild?.text?.includes('canvas cover')) {
pass('7a: Overlay contains flushed text');
} else {
fail('7a: Flushed text in overlay', `"${overlayChild?.text}"`);
}
if (overlayChild?.bgColor) {
pass('7b: Overlay has background color');
} else {
fail('7b: No background color', `bg="${overlayChild?.bgColor}"`);
}
await page.keyboard.press('Control+c');
await page.waitForTimeout(500);
// ══════════════════════════════════════════════════════════════
// TEST 8: Backspace ALL flushed text back to zero
// ══════════════════════════════════════════════════════════════
console.log('\n--- 8. Full backspace through flushed text ---');
await focus(page);
await waitForPrompt(page);
await page.keyboard.type('abcde', { delay: 20 });
await page.waitForTimeout(200);
// Flush
await switchTab(page, sB);
await switchTab(page, sA);
await focus(page);
st = await getState(page);
console.log(` Flushed: ${st?.flushed}`);
// Backspace ALL 5 chars + 2 extra (should be no-ops)
for (let i = 0; i < 7; i++) {
await page.keyboard.press('Backspace');
await page.waitForTimeout(40);
}
await page.waitForTimeout(300);
st = await getState(page);
console.log(` After full backspace: flushed=${st?.flushed}, text="${st?.text}", visible=${st?.visible}`);
await sc(page, '10-full-backspace');
if (st?.flushed === 0) pass('8a: Flushed offset back to 0');
else fail('8a: Flushed not zero', `${st?.flushed}`);
if (!st?.text && !st?.visible) pass('8b: Overlay hidden after full backspace');
else fail('8b: Overlay state', `text="${st?.text}", visible=${st?.visible}`);
await page.keyboard.press('Control+c');
await page.waitForTimeout(500);
// ══════════════════════════════════════════════════════════════
// TEST 9: Fresh type after full backspace
// ══════════════════════════════════════════════════════════════
console.log('\n--- 9. Type fresh after full backspace ---');
await focus(page);
await waitForPrompt(page);
await page.keyboard.type('delete me', { delay: 20 });
await page.waitForTimeout(200);
// Flush
await switchTab(page, sB);
await switchTab(page, sA);
await focus(page);
// Delete all
for (let i = 0; i < 12; i++) {
await page.keyboard.press('Backspace');
await page.waitForTimeout(30);
}
await page.waitForTimeout(200);
// Now type fresh text
await page.keyboard.type('brand new', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
console.log(` Fresh type: text="${st?.text}", flushed=${st?.flushed}, visible=${st?.visible}`);
await sc(page, '11-fresh-type');
if (st?.text === 'brand new' && st?.flushed === 0) {
pass('9a: Clean fresh text after full delete');
} else {
fail('9a: Fresh text', `text="${st?.text}", flushed=${st?.flushed}`);
}
if (st?.visible) pass('9b: Overlay visible with fresh text');
else fail('9b: Overlay not visible');
await page.keyboard.press('Control+c');
await page.waitForTimeout(500);
// ══════════════════════════════════════════════════════════════
// JS ERRORS
// ══════════════════════════════════════════════════════════════
console.log('\n--- JS Errors ---');
if (jsErrors.length === 0) {
pass('No JS errors');
} else {
for (const err of jsErrors) {
fail('JS Error', err.slice(0, 120));
}
}
await sc(page, '12-final');
// ══════════════════════════════════════════════════════════════
// SUMMARY
// ══════════════════════════════════════════════════════════════
console.log('\n=== SUMMARY ===');
const passes = R.filter(r => r.s === 'PASS').length;
const fails = R.filter(r => r.s === 'FAIL').length;
console.log(` ${passes} passed, ${fails} failed`);
if (fails > 0) {
console.log('\n FAILURES:');
R.filter(r => r.s === 'FAIL').forEach(r => console.log(` ❌ ${r.n}: ${r.d}`));
}
} finally {
await browser.close();
console.log('\nCleaning up...');
await deleteSession(sA);
await deleteSession(sB);
}
}
main().catch(err => { console.error('Test failed:', err); process.exit(1); });
-395
View File
@@ -1,395 +0,0 @@
/**
* Local Echo Overlay — Daily use scenarios test.
*
* Simulates real mobile usage:
* 1. Type text → switch tabs → switch back → verify state
* 2. Type → change mind → delete all → type something else
* 3. Type → reload page → verify recovery
* 4. Rapidly type-delete-type (simulate editing)
* 5. Type on Tab A → switch B → type on B → switch A → verify both
* 6. Type → Enter → wait for response → type again
* 7. Partial delete then add more text
*
* Usage: node test/local-echo-daily-test.mjs
*/
import { chromium } from 'playwright';
const PORT = 3000;
const BASE = `http://localhost:${PORT}`;
const SCREENSHOT_DIR = '/tmp/local-echo-daily';
async function createSession(name) {
const resp = await fetch(`${BASE}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'claude', name })
});
if (!resp.ok) throw new Error(`Failed: ${resp.status}`);
const data = await resp.json();
return data.session?.id || data.id;
}
async function deleteSession(id) {
await fetch(`${BASE}/api/sessions/${id}`, { method: 'DELETE' }).catch(() => {});
}
async function waitForReady(id, ms = 15000) {
const start = Date.now();
while (Date.now() - start < ms) {
const r = await fetch(`${BASE}/api/sessions/${id}`);
if (r.ok) { const d = await r.json(); if (d.status === 'idle' || d.status === 'busy') return true; }
await new Promise(r => setTimeout(r, 300));
}
return false;
}
async function getState(page) {
return page.evaluate(() => {
const app = window.app;
if (!app) return null;
const ov = app._localEchoOverlay;
return {
visible: ov?.overlay?.style.display !== 'none',
text: ov?.pendingText || '',
flushed: ov?._flushedOffset || 0,
flushedMap: app._flushedOffsets ? Object.fromEntries(app._flushedOffsets) : {},
enabled: app._localEchoEnabled,
activeId: app.activeSessionId,
children: (() => {
if (!ov?.overlay) return [];
return Array.from(ov.overlay.children).map(c => ({
text: Array.from(c.querySelectorAll('span')).map(s => s.textContent).join(''),
left: c.style.left, top: c.style.top,
}));
})(),
};
});
}
async function focus(page) {
await page.evaluate(() => document.querySelector('.xterm-helper-textarea')?.focus());
await page.waitForTimeout(200);
}
async function switchTab(page, id) {
await page.evaluate((id) => document.querySelector(`.session-tab[data-id="${id}"]`)?.click(), id);
await page.waitForTimeout(1500);
}
async function sc(page, name) {
const p = `${SCREENSHOT_DIR}/${name}.png`;
await page.screenshot({ path: p });
console.log(` 📸 ${p}`);
}
const R = [];
const pass = (n) => { R.push({n, s:'PASS'}); console.log(` ✅ ${n}`); };
const fail = (n, d) => { R.push({n, s:'FAIL', d}); console.log(` ❌ ${n} — ${d}`); };
const info = (n, d) => { R.push({n, s:'INFO', d}); console.log(` ℹ️ ${n} — ${d}`); };
async function main() {
const { mkdirSync } = await import('fs');
try { mkdirSync(SCREENSHOT_DIR, { recursive: true }); } catch {}
console.log('=== Local Echo — Daily Use Tests ===\n');
const sA = await createSession('daily-A');
const sB = await createSession('daily-B');
console.log(` A: ${sA}\n B: ${sB}`);
if (!await waitForReady(sA) || !await waitForReady(sB)) {
await deleteSession(sA); await deleteSession(sB);
throw new Error('Sessions not ready');
}
console.log(' Ready\n');
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({
viewport: { width: 390, height: 844 },
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)',
isMobile: true, hasTouch: true,
});
const page = await ctx.newPage();
try {
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.xterm-screen', { timeout: 10000 });
await page.waitForTimeout(2000);
// Enable local echo
await page.evaluate(() => {
const s = JSON.parse(localStorage.getItem('codeman-app-settings') || '{}');
s.localEchoEnabled = true;
localStorage.setItem('codeman-app-settings', JSON.stringify(s));
window.app?._updateLocalEchoState?.();
});
await switchTab(page, sA);
await focus(page);
// ══════════════════════════════════════════
// 1. Type → switch tab → switch back
// ══════════════════════════════════════════
console.log('--- 1. Type → Tab switch → Return ---');
await page.keyboard.type('fix the login bug', { delay: 40 });
await page.waitForTimeout(300);
let st = await getState(page);
await sc(page, '01-typed');
console.log(` Typed: "${st?.text}"`);
if (st?.text === 'fix the login bug') pass('Text buffered');
else fail('Buffer', `"${st?.text}"`);
// Switch to B
await switchTab(page, sB);
st = await getState(page);
console.log(` After switch to B: text="${st?.text}", flushed A=${st?.flushedMap?.[sA]}`);
await sc(page, '02-on-tab-b');
if (!st?.text) pass('Overlay cleared on switch');
else fail('Clear on switch', `"${st?.text}"`);
// Switch back to A
await switchTab(page, sA);
await focus(page);
st = await getState(page);
await sc(page, '03-back-on-a');
console.log(` Back on A: text="${st?.text}", flushed=${st?.flushed}`);
if (!st?.text && st?.flushed > 0) pass('Flushed text preserved on return');
else info('Return state', `text="${st?.text}", flushed=${st?.flushed}`);
// Type more
await page.keyboard.type(' please', { delay: 40 });
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, '04-type-more-on-a');
console.log(` After adding: text="${st?.text}", flushed=${st?.flushed}`);
if (st?.text === ' please' && st?.flushed > 0) pass('Can type after return with flushed offset');
else fail('Type after return', `text="${st?.text}", flushed=${st?.flushed}`);
// Submit
await page.keyboard.press('Enter');
await page.waitForTimeout(2000);
// ══════════════════════════════════════════
// 2. Type → delete all → retype
// ══════════════════════════════════════════
console.log('\n--- 2. Type → delete all → retype ---');
await focus(page);
await page.keyboard.type('wrong command oops', { delay: 30 });
await page.waitForTimeout(200);
st = await getState(page);
console.log(` Typed wrong: "${st?.text}"`);
// Delete all
for (let i = 0; i < 'wrong command oops'.length + 2; i++) {
await page.keyboard.press('Backspace');
await page.waitForTimeout(20);
}
await page.waitForTimeout(200);
st = await getState(page);
console.log(` After delete: text="${st?.text}", visible=${st?.visible}`);
if (!st?.text && !st?.visible) pass('Fully deleted');
else fail('Full delete', `"${st?.text}"`);
// Retype correct command
await page.keyboard.type('say hello', { delay: 40 });
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, '05-retyped');
console.log(` Retyped: "${st?.text}"`);
if (st?.text === 'say hello' && st?.visible) pass('Retype after delete works');
else fail('Retype', `"${st?.text}", visible=${st?.visible}`);
await page.keyboard.press('Enter');
await page.waitForTimeout(2000);
// ══════════════════════════════════════════
// 3. Type → reload page → check recovery
// ══════════════════════════════════════════
console.log('\n--- 3. Type → reload page → recovery ---');
await focus(page);
await page.keyboard.type('unsent text before reload', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
console.log(` Before reload: "${st?.text}"`);
await sc(page, '06-before-reload');
// Reload the page
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('.xterm-screen', { timeout: 10000 });
await page.waitForTimeout(3000); // Wait for SSE reconnect + buffer load
st = await getState(page);
await sc(page, '07-after-reload');
console.log(` After reload: text="${st?.text}", enabled=${st?.enabled}`);
// localStorage persistence was removed — stale text from previous page
// loads caused ghost-merging with new input, breaking editing.
// After reload, overlay should be clean (no stale text).
if (st?.text === '') {
pass('Overlay clean after reload (no stale text)');
} else {
fail('Stale text after reload', `text="${st?.text}"`);
}
// ══════════════════════════════════════════
// 4. Rapid type-delete-type (simulate editing)
// ══════════════════════════════════════════
console.log('\n--- 4. Rapid type-delete-type editing ---');
await focus(page);
// Type "hello", delete "lo", type "p me"
await page.keyboard.type('hello', { delay: 20 });
await page.waitForTimeout(100);
await page.keyboard.press('Backspace');
await page.keyboard.press('Backspace');
await page.waitForTimeout(100);
await page.keyboard.type('p me', { delay: 20 });
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, '08-rapid-edit');
console.log(` After rapid edit: "${st?.text}"`);
if (st?.text === 'help me') pass('Rapid edit produces correct text');
else fail('Rapid edit', `expected "help me", got "${st?.text}"`);
await page.keyboard.press('Control+c');
await page.waitForTimeout(500);
// ══════════════════════════════════════════
// 5. Interleaved typing on two tabs
// ══════════════════════════════════════════
console.log('\n--- 5. Interleaved typing on two tabs ---');
await focus(page);
await page.keyboard.type('tab A input', { delay: 30 });
await page.waitForTimeout(200);
await sc(page, '09-tab-a-input');
st = await getState(page);
console.log(` Tab A: "${st?.text}"`);
if (st?.text === 'tab A input') pass('Tab A text correct');
else fail('Tab A text', `"${st?.text}"`);
// Switch to B and type
await switchTab(page, sB);
await focus(page);
await page.keyboard.type('tab B input', { delay: 30 });
await page.waitForTimeout(200);
await sc(page, '10-tab-b-input');
st = await getState(page);
console.log(` Tab B: "${st?.text}"`);
if (st?.text === 'tab B input') pass('Tab B text correct');
else fail('Tab B text', `"${st?.text}"`);
// Switch back to A — A's text was flushed
await switchTab(page, sA);
await focus(page);
st = await getState(page);
await sc(page, '11-back-to-a');
console.log(` Back to A: text="${st?.text}", flushed=${st?.flushed}, flushedMap A=${st?.flushedMap?.[sA]}, B=${st?.flushedMap?.[sB]}`);
// A's input was flushed (11 chars), B's was flushed (11 chars)
if (st?.flushed > 0) pass('Tab A flushed offset restored');
else info('Tab A flushed', `offset=${st?.flushed}`);
// Switch to B — B's input was flushed
await switchTab(page, sB);
await focus(page);
st = await getState(page);
await sc(page, '12-back-to-b');
console.log(` Back to B: text="${st?.text}", flushed=${st?.flushed}`);
if (st?.flushed > 0) pass('Tab B flushed offset restored');
else info('Tab B flushed', `offset=${st?.flushed}`);
// Submit both with Enter
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
await switchTab(page, sA);
await focus(page);
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
// ══════════════════════════════════════════
// 6. Type → Enter → response → type again
// ══════════════════════════════════════════
console.log('\n--- 6. Type → Enter → wait → type again ---');
await focus(page);
await page.keyboard.type('just say ok', { delay: 30 });
await page.keyboard.press('Enter');
await page.waitForTimeout(4000); // Wait for response
await focus(page);
await page.keyboard.type('now say goodbye', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, '13-second-prompt');
console.log(` Second prompt: "${st?.text}", flushed=${st?.flushed}`);
if (st?.text === 'now say goodbye' && st?.flushed === 0) {
pass('Clean typing on new prompt after response');
} else {
fail('Second prompt', `text="${st?.text}", flushed=${st?.flushed}`);
}
await page.keyboard.press('Control+c');
await page.waitForTimeout(500);
// ══════════════════════════════════════════
// 7. Partial delete then add text
// ══════════════════════════════════════════
console.log('\n--- 7. Partial delete + add ---');
await focus(page);
await page.keyboard.type('fixing the bug', { delay: 30 });
await page.waitForTimeout(200);
// Delete "bug" (3 chars), keeping the space
for (let i = 0; i < 3; i++) await page.keyboard.press('Backspace');
await page.waitForTimeout(100);
// Add "typo"
await page.keyboard.type('typo', { delay: 30 });
await page.waitForTimeout(300);
st = await getState(page);
await sc(page, '14-partial-edit');
console.log(` After partial edit: "${st?.text}"`);
if (st?.text === 'fixing the typo') pass('Partial delete + add correct');
else fail('Partial edit', `expected "fixing the typo", got "${st?.text}"`);
// Final
await page.keyboard.press('Control+c');
await page.waitForTimeout(300);
await sc(page, '15-final');
// ══════════════════════════════════════════
// SUMMARY
// ══════════════════════════════════════════
console.log('\n=== SUMMARY ===');
const p = R.filter(r => r.s === 'PASS').length;
const f = R.filter(r => r.s === 'FAIL').length;
const i = R.filter(r => r.s === 'INFO').length;
console.log(` ${p} passed, ${f} failed, ${i} info`);
if (f > 0) {
console.log('\n FAILURES:');
R.filter(r => r.s === 'FAIL').forEach(r => console.log(` ❌ ${r.n}: ${r.d}`));
}
} finally {
await browser.close();
console.log('\nCleaning up...');
await deleteSession(sA);
await deleteSession(sB);
}
}
main().catch(err => { console.error('Test failed:', err); process.exit(1); });
-533
View File
@@ -1,533 +0,0 @@
/**
* Local Echo Overlay — Comprehensive edge case tests.
*
* Tests: reload, tab switching, backspace, Ctrl+C, paste, rapid switches,
* empty Enter, long text wrap, flushed offset tracking, and font rendering.
*
* Usage: node test/local-echo-edge-test.mjs
*/
import { chromium } from 'playwright';
const PORT = 3000;
const BASE = `http://localhost:${PORT}`;
const SCREENSHOT_DIR = '/tmp/local-echo-edge';
const results = [];
function pass(name) { results.push({ name, ok: true }); console.log(` ✅ ${name}`); }
function fail(name, detail) { results.push({ name, ok: false, detail }); console.log(` ❌ ${name} — ${detail}`); }
async function createSession(name) {
const resp = await fetch(`${BASE}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'claude', name })
});
if (!resp.ok) throw new Error(`Failed to create session '${name}': ${resp.status}`);
const data = await resp.json();
return data.session?.id || data.id;
}
async function deleteSession(id) {
await fetch(`${BASE}/api/sessions/${id}`, { method: 'DELETE' }).catch(() => {});
}
async function waitForReady(id, ms = 15000) {
const start = Date.now();
while (Date.now() - start < ms) {
const r = await fetch(`${BASE}/api/sessions/${id}`);
if (r.ok) { const d = await r.json(); if (d.status === 'idle' || d.status === 'busy') return true; }
await new Promise(r => setTimeout(r, 300));
}
return false;
}
async function getState(page) {
return page.evaluate(() => {
const app = window.app;
if (!app) return null;
const overlay = app._localEchoOverlay;
return {
localEchoEnabled: app._localEchoEnabled,
overlayVisible: overlay?.overlay?.style.display !== 'none',
overlayText: overlay?.pendingText || '',
flushedOffset: overlay?._flushedOffset || 0,
flushedOffsets: app._flushedOffsets ? Object.fromEntries(app._flushedOffsets) : {},
activeSessionId: app.activeSessionId,
overlayChildCount: overlay?.overlay?.children?.length || 0,
overlaySpanCSS: (() => {
const span = overlay?.overlay?.children?.[0]?.children?.[0];
if (!span) return null;
const cs = getComputedStyle(span);
return {
fontSmoothing: cs.webkitFontSmoothing || 'N/A',
textRendering: cs.textRendering,
fontFeatures: cs.fontFeatureSettings,
};
})(),
};
});
}
async function focusTerminal(page) {
await page.evaluate(() => document.querySelector('.xterm-helper-textarea')?.focus());
await page.waitForTimeout(200);
}
async function screenshot(page, name) {
const path = `${SCREENSHOT_DIR}/${name}.png`;
await page.screenshot({ path, fullPage: false });
console.log(` 📸 ${name}.png`);
}
async function selectSession(page, sessionId) {
await page.evaluate((id) => {
const t = document.querySelector(`.session-tab[data-id="${id}"]`);
if (t) t.click();
}, sessionId);
await page.waitForTimeout(1500);
}
async function main() {
const { mkdirSync } = await import('fs');
try { mkdirSync(SCREENSHOT_DIR, { recursive: true }); } catch {}
console.log('=== Local Echo — Comprehensive Edge Case Tests ===\n');
const sA = await createSession('edge-A');
const sB = await createSession('edge-B');
const sC = await createSession('edge-C');
console.log(` Sessions: A=${sA.slice(0,8)}, B=${sB.slice(0,8)}, C=${sC.slice(0,8)}`);
for (const s of [sA, sB, sC]) {
if (!await waitForReady(s)) { console.error(`Session ${s} not ready`); process.exit(1); }
}
console.log(' All ready.\n');
const browser = await chromium.launch({ headless: true, args: ['--window-size=390,844'] });
const ctx = await browser.newContext({
viewport: { width: 390, height: 844 },
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)',
isMobile: true, hasTouch: true,
});
const page = await ctx.newPage();
const jsErrors = [];
page.on('pageerror', err => jsErrors.push(err.message));
try {
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.xterm-screen', { timeout: 10000 });
await page.waitForTimeout(2000);
// Enable local echo
await page.evaluate(() => {
const s = JSON.parse(localStorage.getItem('codeman-app-settings') || '{}');
s.localEchoEnabled = true;
localStorage.setItem('codeman-app-settings', JSON.stringify(s));
window.app?._updateLocalEchoState?.();
});
// ═══════════════════════════════════════════
// 1. Fresh typing on session A
// ═══════════════════════════════════════════
console.log('── 1. Fresh typing ──');
await selectSession(page, sA);
// Wait for ❯ prompt to appear in terminal buffer (Claude Code startup)
for (let i = 0; i < 20; i++) {
const hasPrompt = await page.evaluate(() => {
const buf = window.app?.terminal?.buffer?.active;
if (!buf) return false;
for (let r = 0; r < window.app.terminal.rows; r++) {
const line = buf.getLine(buf.viewportY + r);
if (line && line.translateToString(true).includes('\u276f')) return true;
}
return false;
});
if (hasPrompt) break;
await page.waitForTimeout(500);
}
await focusTerminal(page);
await page.keyboard.type('hello world', { delay: 20 });
await page.waitForTimeout(200);
let st = await getState(page);
if (st.overlayText === 'hello world' && st.overlayVisible) pass('Fresh typing shows in overlay');
else fail('Fresh typing', `text="${st.overlayText}", visible=${st.overlayVisible}`);
if (st.overlaySpanCSS?.textRendering === 'geometricprecision') pass('Font CSS applied');
else fail('Font CSS', JSON.stringify(st.overlaySpanCSS));
await screenshot(page, '01-fresh-type');
// ═══════════════════════════════════════════
// 2. Backspace removes from overlay (not PTY)
// ═══════════════════════════════════════════
console.log('── 2. Backspace ──');
await page.keyboard.press('Backspace');
await page.keyboard.press('Backspace');
await page.waitForTimeout(200);
st = await getState(page);
if (st.overlayText === 'hello wor') pass('Backspace removes chars from overlay');
else fail('Backspace', `text="${st.overlayText}"`);
// Backspace to empty
for (let i = 0; i < 20; i++) await page.keyboard.press('Backspace');
await page.waitForTimeout(200);
st = await getState(page);
if (st.overlayText === '' && !st.overlayVisible) pass('Backspace to empty hides overlay');
else fail('Backspace to empty', `text="${st.overlayText}", visible=${st.overlayVisible}`);
// Extra backspace on empty — should not crash
await page.keyboard.press('Backspace');
await page.keyboard.press('Backspace');
await page.waitForTimeout(100);
st = await getState(page);
if (st.overlayText === '') pass('Extra backspace on empty is no-op');
else fail('Extra backspace', `text="${st.overlayText}"`);
// ═══════════════════════════════════════════
// 3. Enter on empty prompt — no-op
// ═══════════════════════════════════════════
console.log('── 3. Enter on empty ──');
await page.keyboard.press('Enter');
await page.waitForTimeout(500);
st = await getState(page);
if (st.overlayText === '' && st.flushedOffset === 0) pass('Enter on empty is harmless');
else fail('Enter on empty', `text="${st.overlayText}", flushed=${st.flushedOffset}`);
// ═══════════════════════════════════════════
// 4. Tab switch A→B: text flushed to PTY
// ═══════════════════════════════════════════
console.log('── 4. Tab switch flush ──');
await focusTerminal(page);
await page.keyboard.type('flush-me', { delay: 20 });
await page.waitForTimeout(200);
await selectSession(page, sB);
await page.waitForTimeout(500);
st = await getState(page);
if (st.overlayText === '') pass('Overlay cleared after tab switch');
else fail('Overlay after switch', `text="${st.overlayText}"`);
const flushedA = st.flushedOffsets[sA] || 0;
if (flushedA === 8) pass('Flushed offset tracked for session A (8 chars)');
else fail('Flushed offset', `expected 8, got ${flushedA}`);
// ═══════════════════════════════════════════
// 5. Type on B, switch to C, switch back to B
// ═══════════════════════════════════════════
console.log('── 5. Triple switch B→C→B ──');
await focusTerminal(page);
await page.keyboard.type('on-B', { delay: 20 });
await page.waitForTimeout(200);
await selectSession(page, sC);
await page.waitForTimeout(500);
st = await getState(page);
const flushedB = st.flushedOffsets[sB] || 0;
if (flushedB === 4) pass('B flushed on switch to C');
else fail('B flush', `expected 4, got ${flushedB}`);
await focusTerminal(page);
await page.keyboard.type('on-C', { delay: 20 });
await page.waitForTimeout(200);
await selectSession(page, sB);
await page.waitForTimeout(500);
st = await getState(page);
if (st.overlayText === '') pass('Overlay empty on return to B');
else fail('Return to B overlay', `text="${st.overlayText}"`);
if (st.flushedOffset === 4) pass('Flushed offset restored for B');
else fail('B flushed offset restore', `expected 4, got ${st.flushedOffset}`);
await screenshot(page, '05-return-to-B');
// ═══════════════════════════════════════════
// 6. Type after return (append to flushed)
// ═══════════════════════════════════════════
console.log('── 6. Type after return to flushed session ──');
await focusTerminal(page);
await page.keyboard.type(' more', { delay: 20 });
await page.waitForTimeout(200);
st = await getState(page);
if (st.overlayText === ' more' && st.flushedOffset === 4) pass('Append after flushed');
else fail('Append after flush', `text="${st.overlayText}", flushed=${st.flushedOffset}`);
await screenshot(page, '06-append-after-flush');
// ═══════════════════════════════════════════
// 7. Enter clears flushed offset
// ═══════════════════════════════════════════
console.log('── 7. Enter clears flushed offset ──');
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
st = await getState(page);
if (st.overlayText === '' && st.flushedOffset === 0) pass('Enter clears overlay + flushed');
else fail('Enter clear', `text="${st.overlayText}"`);
const bInMap = st.flushedOffsets[sB];
if (!bInMap) pass('Flushed offset map cleared for B');
else fail('Map clear', `B still in map: ${bInMap}`);
// ═══════════════════════════════════════════
// 8. Ctrl+C during typing
// ═══════════════════════════════════════════
console.log('── 8. Ctrl+C during typing ──');
await page.waitForTimeout(3000);
await focusTerminal(page);
await page.keyboard.type('will-be-cancelled', { delay: 20 });
await page.waitForTimeout(200);
await page.keyboard.down('Control');
await page.keyboard.press('c');
await page.keyboard.up('Control');
await page.waitForTimeout(500);
st = await getState(page);
if (st.overlayText === '') pass('Ctrl+C clears overlay');
else fail('Ctrl+C clear', `text="${st.overlayText}"`);
await screenshot(page, '08-ctrl-c');
// ═══════════════════════════════════════════
// 9. Paste (multi-char data)
// ═══════════════════════════════════════════
console.log('── 9. Paste ──');
await page.waitForTimeout(2000);
await focusTerminal(page);
await page.evaluate(() => {
window.app?._localEchoOverlay?.appendText('pasted-text');
});
await page.waitForTimeout(200);
st = await getState(page);
if (st.overlayText === 'pasted-text') pass('Paste appends to overlay');
else fail('Paste', `text="${st.overlayText}"`);
await page.keyboard.down('Control');
await page.keyboard.press('c');
await page.keyboard.up('Control');
await page.waitForTimeout(500);
// ═══════════════════════════════════════════
// 10. Reload — stale text NOT restored
// ═══════════════════════════════════════════
console.log('── 10. Browser reload ──');
await page.waitForTimeout(2000);
await focusTerminal(page);
await page.keyboard.type('will-be-lost', { delay: 20 });
await page.waitForTimeout(200);
const lsBefore = await page.evaluate(() => localStorage.getItem('codeman_local_echo_pending'));
if (!lsBefore) pass('localStorage not written (persist is no-op)');
else fail('localStorage written', `value="${lsBefore}"`);
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('.xterm-screen', { timeout: 10000 });
await page.waitForTimeout(3000);
await page.evaluate(() => window.app?._updateLocalEchoState?.());
st = await getState(page);
if (st.overlayText === '') pass('Overlay empty after reload (no stale text)');
else fail('Stale text after reload', `text="${st.overlayText}"`);
if (st.flushedOffset === 0) pass('Flushed offset reset after reload');
else fail('Flushed offset after reload', `flushed=${st.flushedOffset}`);
if (Object.keys(st.flushedOffsets).length === 0) pass('Flushed offsets map empty after reload');
else fail('Flushed map after reload', JSON.stringify(st.flushedOffsets));
// ═══════════════════════════════════════════
// 11. Type after reload — works normally
// ═══════════════════════════════════════════
console.log('── 11. Type after reload ──');
await focusTerminal(page);
await page.keyboard.type('after reload', { delay: 20 });
await page.waitForTimeout(300);
st = await getState(page);
if (st.overlayText === 'after reload' && st.overlayVisible) pass('Typing works after reload');
else fail('Type after reload', `text="${st.overlayText}", visible=${st.overlayVisible}`);
await screenshot(page, '11-after-reload');
await page.keyboard.press('Backspace');
await page.keyboard.press('Backspace');
await page.waitForTimeout(200);
st = await getState(page);
if (st.overlayText === 'after relo') pass('Backspace works after reload');
else fail('Backspace after reload', `text="${st.overlayText}"`);
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
st = await getState(page);
if (st.overlayText === '' && st.flushedOffset === 0) pass('Enter works after reload');
else fail('Enter after reload', `text="${st.overlayText}"`);
await screenshot(page, '11-enter-after-reload');
// ═══════════════════════════════════════════
// 12. Rapid tab switching (no typing between)
// ═══════════════════════════════════════════
console.log('── 12. Rapid tab switching ──');
for (let i = 0; i < 3; i++) {
await selectSession(page, sA);
await page.waitForTimeout(200);
await selectSession(page, sB);
await page.waitForTimeout(200);
await selectSession(page, sC);
await page.waitForTimeout(200);
}
st = await getState(page);
if (st.overlayText === '' && st.flushedOffset === 0) pass('Rapid switches — clean state');
else fail('Rapid switches', `text="${st.overlayText}", flushed=${st.flushedOffset}`);
// ═══════════════════════════════════════════
// 13. Backspace on flushed text (no pending)
// ═══════════════════════════════════════════
console.log('── 13. Backspace on flushed text ──');
await selectSession(page, sA);
await page.waitForTimeout(3000);
await focusTerminal(page);
await page.keyboard.type('abc', { delay: 20 });
await page.waitForTimeout(200);
await selectSession(page, sB);
await page.waitForTimeout(500);
await selectSession(page, sA);
await page.waitForTimeout(500);
st = await getState(page);
const flushedA2 = st.flushedOffset;
console.log(` Flushed offset for A: ${flushedA2}`);
await focusTerminal(page);
if (flushedA2 > 0) {
await page.keyboard.press('Backspace');
await page.waitForTimeout(300);
st = await getState(page);
if (st.flushedOffset === flushedA2 - 1) pass('Backspace decrements flushed offset');
else fail('Backspace flushed', `expected ${flushedA2 - 1}, got ${st.flushedOffset}`);
} else {
pass('Flushed offset was 0 — skip backspace flushed test');
}
// ═══════════════════════════════════════════
// 14. Overlay hidden when scrolled up
// ═══════════════════════════════════════════
console.log('── 14. Overlay hidden on scroll up ──');
await focusTerminal(page);
await page.keyboard.down('Control');
await page.keyboard.press('c');
await page.keyboard.up('Control');
await page.waitForTimeout(1000);
await focusTerminal(page);
await page.keyboard.type('scroll-test', { delay: 20 });
await page.waitForTimeout(200);
await page.evaluate(() => window.app?.terminal?.scrollToTop());
await page.waitForTimeout(300);
// Check if scroll actually happened (buffer may be too short for viewport)
const scrollState = await page.evaluate(() => {
const buf = window.app?.terminal?.buffer?.active;
return { viewportY: buf?.viewportY, baseY: buf?.baseY };
});
if (scrollState.viewportY === scrollState.baseY) {
// Buffer too short to scroll — skip scroll hide/show tests
pass('Scroll up hide — SKIP (buffer too short to scroll, viewportY===baseY)');
pass('Text preserved while scrolled — SKIP');
pass('Overlay reappears at bottom — SKIP');
console.log(` (viewportY=${scrollState.viewportY}, baseY=${scrollState.baseY})`);
} else {
st = await getState(page);
if (!st.overlayVisible) pass('Overlay hidden when scrolled up');
else fail('Scroll up hide', `visible=${st.overlayVisible}`);
if (st.overlayText === 'scroll-test') pass('Text preserved while scrolled');
else fail('Text during scroll', `text="${st.overlayText}"`);
await page.evaluate(() => window.app?.terminal?.scrollToBottom());
await page.waitForTimeout(500);
await page.keyboard.type('x', { delay: 20 });
await page.waitForTimeout(500);
st = await getState(page);
if (st.overlayVisible) pass('Overlay reappears at bottom');
else fail('Scroll bottom show', `visible=${st.overlayVisible}`);
}
// ═══════════════════════════════════════════
// 15. Double reload
// ═══════════════════════════════════════════
console.log('── 15. Double reload ──');
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('.xterm-screen', { timeout: 10000 });
await page.waitForTimeout(1500);
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('.xterm-screen', { timeout: 10000 });
await page.waitForTimeout(2000);
st = await getState(page);
if (st.overlayText === '') pass('Clean state after double reload');
else fail('Double reload', `text="${st.overlayText}"`);
await focusTerminal(page);
await page.keyboard.type('post-double-reload', { delay: 20 });
await page.waitForTimeout(200);
st = await getState(page);
if (st.overlayText === 'post-double-reload') pass('Input works after double reload');
else fail('Post double reload type', `text="${st.overlayText}"`);
await screenshot(page, '15-double-reload');
// ═══════════════════════════════════════════
// 16. Ctrl+C clears flushed offset too
// ═══════════════════════════════════════════
console.log('── 16. Ctrl+C clears flushed offsets ──');
// Clear current text
await page.keyboard.down('Control');
await page.keyboard.press('c');
await page.keyboard.up('Control');
await page.waitForTimeout(2000);
await focusTerminal(page);
await page.keyboard.type('flush-ctrl-c', { delay: 20 });
await page.waitForTimeout(200);
// Switch to flush
await selectSession(page, sB);
await page.waitForTimeout(500);
// Switch back
await selectSession(page, sA);
await page.waitForTimeout(500);
st = await getState(page);
const preFlushed = st.flushedOffset;
// Now Ctrl+C — should clear flushed offset
await focusTerminal(page);
await page.keyboard.down('Control');
await page.keyboard.press('c');
await page.keyboard.up('Control');
await page.waitForTimeout(500);
st = await getState(page);
if (st.flushedOffset === 0) pass('Ctrl+C clears flushed offset');
else fail('Ctrl+C flushed', `expected 0, got ${st.flushedOffset} (was ${preFlushed})`);
const aInMap = st.flushedOffsets[sA];
if (!aInMap) pass('Ctrl+C clears flushed offsets map for session');
else fail('Ctrl+C map', `A still in map: ${aInMap}`);
// ═══════════════════════════════════════════
// 17. JS errors check
// ═══════════════════════════════════════════
console.log('── 17. JS errors ──');
if (jsErrors.length === 0) pass('No JS errors during all tests');
else {
fail('JS errors found', `${jsErrors.length} errors`);
jsErrors.forEach(e => console.log(` ⚠️ ${e}`));
}
// ═══════════════════════════════════════════
// Summary
// ═══════════════════════════════════════════
console.log('\n═══════════════════════════════════');
const passed = results.filter(r => r.ok).length;
const failed = results.filter(r => !r.ok).length;
console.log(` ${passed} passed, ${failed} failed out of ${results.length} checks`);
if (failed > 0) {
console.log('\n Failures:');
results.filter(r => !r.ok).forEach(r => console.log(` ❌ ${r.name}: ${r.detail}`));
}
console.log('═══════════════════════════════════\n');
} finally {
await browser.close();
console.log(' Cleaning up sessions...');
await deleteSession(sA);
await deleteSession(sB);
await deleteSession(sC);
console.log(' Done.\n');
}
}
main().catch(e => { console.error(e); process.exit(1); });
-717
View File
@@ -1,717 +0,0 @@
/**
* Local Echo Overlay — Mobile-focused integration test.
*
* Tests:
* 1. Type text → verify overlay renders with correct font/position
* 2. Switch tabs → verify text is flushed and overlay clears
* 3. Switch back → type more → verify overlay still works
* 4. Backspace (delete chars) from old and new input
* 5. Font matching — overlay font vs terminal font comparison
* 6. Long text wrapping across lines
*
* Takes screenshots at every step for visual verification.
*
* Usage: node test/local-echo-mobile-test.mjs
*/
import { chromium } from 'playwright';
const PORT = 3000;
const BASE = `http://localhost:${PORT}`;
const SCREENSHOT_DIR = '/tmp/local-echo-mobile';
async function createSession(name) {
const resp = await fetch(`${BASE}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'shell', name })
});
if (!resp.ok) throw new Error(`Failed to create session '${name}': ${resp.status}`);
const data = await resp.json();
return data.session?.id || data.id;
}
async function deleteSession(id) {
await fetch(`${BASE}/api/sessions/${id}`, { method: 'DELETE' }).catch(() => {});
}
async function waitForReady(sessionId, timeoutMs = 10000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const resp = await fetch(`${BASE}/api/sessions/${sessionId}`);
if (resp.ok) {
const data = await resp.json();
if (data.status === 'idle' || data.status === 'busy') return true;
}
await new Promise(r => setTimeout(r, 200));
}
return false;
}
/** Get overlay and terminal font/position info from the page */
async function getOverlayState(page) {
return page.evaluate(() => {
const app = window.app;
if (!app) return null;
// Find overlay container
const overlay = app._localEchoOverlay?.overlay;
const overlayVisible = overlay && overlay.style.display !== 'none';
const overlayText = app._localEchoOverlay?.pendingText || '';
// Get overlay font styles from first span child
let overlayFont = null;
if (overlay) {
const span = overlay.querySelector('span');
if (span) {
const cs = getComputedStyle(span);
overlayFont = {
fontFamily: cs.fontFamily,
fontSize: cs.fontSize,
fontWeight: cs.fontWeight,
color: cs.color,
letterSpacing: cs.letterSpacing,
};
}
}
// Get terminal font styles from .xterm-rows
let terminalFont = null;
const rows = document.querySelector('.xterm-rows');
if (rows) {
// Find an actual character span in the rendered rows
const charSpan = rows.querySelector('span[style]') || rows.querySelector('span');
const cs = getComputedStyle(rows);
terminalFont = {
fontFamily: cs.fontFamily,
fontSize: cs.fontSize,
fontWeight: cs.fontWeight,
color: cs.color,
letterSpacing: cs.letterSpacing,
};
// Also get char-level font if available
if (charSpan) {
const charCs = getComputedStyle(charSpan);
terminalFont.charFontFamily = charCs.fontFamily;
terminalFont.charFontSize = charCs.fontSize;
}
}
// Get overlay DOM children info
const overlayChildren = [];
if (overlay) {
for (const child of overlay.children) {
const spans = child.querySelectorAll('span');
const text = Array.from(spans).map(s => s.textContent).join('');
overlayChildren.push({
text,
left: child.style.left,
top: child.style.top,
width: child.style.width,
spanCount: spans.length,
});
}
}
// Get overlay position
const overlayRect = overlay?.getBoundingClientRect();
return {
overlayVisible,
overlayText,
overlayFont,
terminalFont,
overlayChildren,
overlayRect: overlayRect ? {
x: Math.round(overlayRect.x),
y: Math.round(overlayRect.y),
width: Math.round(overlayRect.width),
height: Math.round(overlayRect.height),
} : null,
localEchoEnabled: app._localEchoEnabled,
activeSessionId: app.activeSessionId,
flushedOffset: app._localEchoOverlay?._flushedOffset || 0,
flushedOffsets: app._flushedOffsets ? Object.fromEntries(app._flushedOffsets) : {},
};
});
}
/** Get list of session tab IDs */
async function getSessionTabs(page) {
return page.evaluate(() => {
const tabs = document.querySelectorAll('.session-tab');
return Array.from(tabs).map(t => ({
id: t.dataset.id,
name: t.querySelector('.tab-name')?.textContent || t.textContent?.trim(),
active: t.classList.contains('active'),
}));
});
}
/** Click a session tab by ID */
async function clickTab(page, sessionId) {
await page.evaluate((id) => {
const tab = document.querySelector(`.session-tab[data-id="${id}"]`);
if (tab) tab.click();
}, sessionId);
await page.waitForTimeout(1500); // Wait for buffer load
}
async function screenshot(page, name) {
const path = `${SCREENSHOT_DIR}/${name}.png`;
await page.screenshot({ path, fullPage: false });
console.log(` 📸 ${path}`);
return path;
}
function compareFonts(overlayFont, terminalFont) {
if (!overlayFont || !terminalFont) return { match: false, reason: 'missing font data' };
const issues = [];
if (overlayFont.fontFamily !== terminalFont.fontFamily) {
issues.push(`fontFamily: overlay="${overlayFont.fontFamily}" vs terminal="${terminalFont.fontFamily}"`);
}
if (overlayFont.fontSize !== terminalFont.fontSize) {
issues.push(`fontSize: overlay="${overlayFont.fontSize}" vs terminal="${terminalFont.fontSize}"`);
}
if (overlayFont.fontWeight !== terminalFont.fontWeight) {
issues.push(`fontWeight: overlay="${overlayFont.fontWeight}" vs terminal="${terminalFont.fontWeight}"`);
}
if (overlayFont.letterSpacing !== terminalFont.letterSpacing) {
issues.push(`letterSpacing: overlay="${overlayFont.letterSpacing}" vs terminal="${terminalFont.letterSpacing}"`);
}
return { match: issues.length === 0, issues };
}
async function main() {
// Create screenshot directory
const { mkdirSync } = await import('fs');
try { mkdirSync(SCREENSHOT_DIR, { recursive: true }); } catch {}
console.log('=== Local Echo Overlay — Mobile Test ===\n');
// Create two shell sessions for tab-switching tests
console.log('Creating test sessions...');
const sessionA = await createSession('echo-test-A');
const sessionB = await createSession('echo-test-B');
console.log(` Session A: ${sessionA}`);
console.log(` Session B: ${sessionB}`);
const readyA = await waitForReady(sessionA);
const readyB = await waitForReady(sessionB);
if (!readyA || !readyB) {
console.error('Sessions failed to become ready');
await deleteSession(sessionA);
await deleteSession(sessionB);
process.exit(1);
}
console.log(' Both sessions ready\n');
// Launch browser with mobile-like viewport
const browser = await chromium.launch({
headless: true,
args: ['--window-size=390,844']
});
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
isMobile: true,
hasTouch: true,
});
const page = await context.newPage();
const errors = [];
page.on('console', msg => { if (msg.type() === 'error') errors.push(msg.text()); });
const results = [];
function pass(name) { results.push({ name, status: 'PASS' }); console.log(` ✅ PASS: ${name}`); }
function fail(name, detail) { results.push({ name, status: 'FAIL', detail }); console.log(` ❌ FAIL: ${name} — ${detail}`); }
function info(name, detail) { results.push({ name, status: 'INFO', detail }); console.log(` ℹ️ INFO: ${name} — ${detail}`); }
try {
// ── SETUP ──
console.log('Opening Codeman...');
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.xterm-screen', { timeout: 10000 });
await page.waitForTimeout(2000);
// Enable local echo via settings
console.log('Enabling local echo...');
await page.evaluate(() => {
const settings = JSON.parse(localStorage.getItem('codeman-app-settings') || '{}');
settings.localEchoEnabled = true;
localStorage.setItem('codeman-app-settings', JSON.stringify(settings));
window.app?._updateLocalEchoState?.();
});
await page.waitForTimeout(300);
// Switch to session A
console.log('Switching to Session A...');
await clickTab(page, sessionA);
await page.waitForTimeout(1000);
// Focus terminal — use the hidden textarea (xterm-viewport intercepts pointer events)
await page.evaluate(() => {
const ta = document.querySelector('.xterm-helper-textarea');
if (ta) ta.focus();
});
await page.waitForTimeout(300);
let state = await getOverlayState(page);
if (state?.localEchoEnabled) {
pass('Local echo enabled');
} else {
fail('Local echo enabled', `localEchoEnabled=${state?.localEchoEnabled}`);
}
await screenshot(page, '01-initial-session-a');
// ── TEST 1: Type text on Session A ──
console.log('\n--- Test 1: Type text on Session A ---');
const textA = 'hello-from-session-a';
await page.keyboard.type(textA, { delay: 50 });
await page.waitForTimeout(500);
state = await getOverlayState(page);
await screenshot(page, '02-typed-text-session-a');
console.log(` Overlay visible: ${state?.overlayVisible}`);
console.log(` Overlay text: "${state?.overlayText}"`);
console.log(` Overlay children: ${state?.overlayChildren?.length}`);
if (state?.overlayVisible && state?.overlayText === textA) {
pass('Overlay shows typed text');
} else if (state?.overlayText === textA) {
info('Overlay has text but not visible', `visible=${state?.overlayVisible}`);
} else {
fail('Overlay shows typed text', `expected="${textA}" got="${state?.overlayText}"`);
}
// ── TEST 2: Font matching ──
// Compare overlay's cached font values against xterm.js options (always available,
// unlike .xterm-rows DOM spans which don't exist with WebGL/canvas renderer).
console.log('\n--- Test 2: Font matching ---');
const xtermFont = await page.evaluate(() => {
const t = window.app?.terminal;
if (!t) return null;
const overlay = window.app?._localEchoOverlay;
return {
xtermOpts: {
fontFamily: t.options.fontFamily,
fontSize: t.options.fontSize + 'px',
fontWeight: String(t.options.fontWeight || 'normal'),
},
overlayCached: {
fontFamily: overlay?._fontFamily,
fontSize: overlay?._fontSize,
fontWeight: String(overlay?._fontWeight || 'normal'),
},
};
});
console.log(` xterm opts: ${JSON.stringify(xtermFont?.xtermOpts)}`);
console.log(` overlay cached: ${JSON.stringify(xtermFont?.overlayCached)}`);
if (xtermFont) {
const fontIssues = [];
if (xtermFont.overlayCached.fontFamily !== xtermFont.xtermOpts.fontFamily)
fontIssues.push(`fontFamily: "${xtermFont.overlayCached.fontFamily}" vs "${xtermFont.xtermOpts.fontFamily}"`);
if (xtermFont.overlayCached.fontSize !== xtermFont.xtermOpts.fontSize)
fontIssues.push(`fontSize: "${xtermFont.overlayCached.fontSize}" vs "${xtermFont.xtermOpts.fontSize}"`);
if (xtermFont.overlayCached.fontWeight !== xtermFont.xtermOpts.fontWeight)
fontIssues.push(`fontWeight: "${xtermFont.overlayCached.fontWeight}" vs "${xtermFont.xtermOpts.fontWeight}"`);
if (fontIssues.length === 0) {
pass('Overlay font matches xterm.js options');
} else {
fail('Font mismatch vs xterm options', fontIssues.join('; '));
}
} else {
fail('Font comparison', 'Could not read xterm options');
}
// ── TEST 3: Backspace — delete some chars ──
console.log('\n--- Test 3: Backspace on Session A ---');
// Delete last 5 chars
for (let i = 0; i < 5; i++) {
await page.keyboard.press('Backspace');
await page.waitForTimeout(50);
}
await page.waitForTimeout(300);
state = await getOverlayState(page);
await screenshot(page, '03-after-backspace-session-a');
const expectedAfterBackspace = textA.slice(0, -5); // "hello-from-sess"
console.log(` Expected: "${expectedAfterBackspace}"`);
console.log(` Got: "${state?.overlayText}"`);
if (state?.overlayText === expectedAfterBackspace) {
pass('Backspace deletes from overlay correctly');
} else {
fail('Backspace result', `expected="${expectedAfterBackspace}" got="${state?.overlayText}"`);
}
// ── TEST 4: Switch to Session B (text should flush) ──
console.log('\n--- Test 4: Switch to Session B ---');
const textBeforeSwitch = state?.overlayText || '';
await clickTab(page, sessionB);
state = await getOverlayState(page);
await screenshot(page, '04-switched-to-session-b');
console.log(` Active session: ${state?.activeSessionId}`);
console.log(` Overlay visible: ${state?.overlayVisible}`);
console.log(` Overlay text: "${state?.overlayText}"`);
console.log(` Flushed offsets: ${JSON.stringify(state?.flushedOffsets)}`);
if (state?.activeSessionId === sessionB) {
pass('Switched to Session B');
} else {
fail('Tab switch', `active=${state?.activeSessionId}, expected=${sessionB}`);
}
if (!state?.overlayVisible && !state?.overlayText) {
pass('Overlay cleared on tab switch');
} else {
fail('Overlay not cleared', `visible=${state?.overlayVisible}, text="${state?.overlayText}"`);
}
// Check if text was flushed to Session A
if (state?.flushedOffsets?.[sessionA] > 0) {
pass(`Text flushed to Session A (${state.flushedOffsets[sessionA]} chars)`);
} else {
info('Flushed offsets', JSON.stringify(state?.flushedOffsets));
}
// ── TEST 5: Type on Session B ──
console.log('\n--- Test 5: Type on Session B ---');
await page.evaluate(() => { document.querySelector('.xterm-helper-textarea')?.focus(); });
await page.waitForTimeout(300);
const textB = 'hello-from-session-b';
await page.keyboard.type(textB, { delay: 50 });
await page.waitForTimeout(500);
state = await getOverlayState(page);
await screenshot(page, '05-typed-on-session-b');
console.log(` Overlay visible: ${state?.overlayVisible}`);
console.log(` Overlay text: "${state?.overlayText}"`);
if (state?.overlayVisible && state?.overlayText === textB) {
pass('Overlay works on Session B');
} else {
fail('Session B overlay', `expected="${textB}" got="${state?.overlayText}"`);
}
// ── TEST 6: Switch back to Session A ──
console.log('\n--- Test 6: Switch back to Session A ---');
await clickTab(page, sessionA);
state = await getOverlayState(page);
await screenshot(page, '06-back-to-session-a');
console.log(` Active session: ${state?.activeSessionId}`);
console.log(` Overlay visible: ${state?.overlayVisible}`);
console.log(` Overlay text: "${state?.overlayText}"`);
console.log(` Flushed offset on overlay: ${state?.flushedOffset}`);
console.log(` Flushed offsets map: ${JSON.stringify(state?.flushedOffsets)}`);
// Session A's text was flushed before we left — it should have been sent to PTY.
// The overlay should be clear (text was flushed, not pending anymore).
if (!state?.overlayText) {
pass('Session A overlay clear after return (text was flushed)');
} else {
info('Session A has overlay text after return', `"${state?.overlayText}"`);
}
// ── TEST 7: Type NEW text on Session A after returning ──
console.log('\n--- Test 7: Type new text after returning to Session A ---');
await page.evaluate(() => { document.querySelector('.xterm-helper-textarea')?.focus(); });
await page.waitForTimeout(300);
const textA2 = 'new-text-after-return';
await page.keyboard.type(textA2, { delay: 50 });
await page.waitForTimeout(500);
state = await getOverlayState(page);
await screenshot(page, '07-new-text-session-a');
console.log(` Overlay visible: ${state?.overlayVisible}`);
console.log(` Overlay text: "${state?.overlayText}"`);
console.log(` Flushed offset: ${state?.flushedOffset}`);
console.log(` Overlay children: ${JSON.stringify(state?.overlayChildren)}`);
if (state?.overlayText === textA2) {
pass('Can type new text after tab switch return');
} else {
fail('New text after return', `expected="${textA2}" got="${state?.overlayText}"`);
}
// ── TEST 8: Delete ALL chars with backspace ──
console.log('\n--- Test 8: Delete all chars with backspace ---');
const deleteCount = (state?.overlayText || '').length;
for (let i = 0; i < deleteCount + 3; i++) { // +3 extra to test over-delete
await page.keyboard.press('Backspace');
await page.waitForTimeout(30);
}
await page.waitForTimeout(300);
state = await getOverlayState(page);
await screenshot(page, '08-all-deleted');
console.log(` Overlay visible: ${state?.overlayVisible}`);
console.log(` Overlay text: "${state?.overlayText}"`);
// After deleting all typed (pending) text, overlay stays visible because
// flushed text still exists (3 extra backspaces only reduced flushed from 15→12).
// The overlay correctly shows flushed text so the user can keep backspacing.
if (state?.flushedOffset > 0 && state?.overlayVisible && !state?.overlayText) {
pass('Overlay visible with flushed text after deleting pending');
} else if (!state?.overlayVisible && !state?.overlayText && state?.flushedOffset === 0) {
pass('Overlay hidden after deleting all chars');
} else {
fail('Unexpected state after full delete', `visible=${state?.overlayVisible}, text="${state?.overlayText}", flushed=${state?.flushedOffset}`);
}
// Verify backspace-into-flushed-text: the 3 extra backspaces should have
// decremented flushedOffset from 15 to 12 and sent \x7f to PTY.
const expectedFlushedAfterDelete = 15 - 3; // original 15, minus 3 extras
console.log(` Flushed offset after delete: ${state?.flushedOffset} (expected ${expectedFlushedAfterDelete})`);
if (state?.flushedOffset === expectedFlushedAfterDelete) {
pass('Backspace into flushed text decrements offset correctly');
} else {
fail('Flushed offset after backspace', `expected ${expectedFlushedAfterDelete}, got ${state?.flushedOffset}`);
}
// ── TEST 9: Type, submit with Enter, verify overlay clears ──
console.log('\n--- Test 9: Enter submits and clears overlay ---');
const enterText = 'echo submit-test';
await page.keyboard.type(enterText, { delay: 50 });
await page.waitForTimeout(300);
state = await getOverlayState(page);
await screenshot(page, '09-before-enter');
if (state?.overlayText === enterText) {
pass('Text buffered before Enter');
} else {
info('Pre-enter text', `"${state?.overlayText}"`);
}
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
state = await getOverlayState(page);
await screenshot(page, '10-after-enter');
console.log(` Overlay visible: ${state?.overlayVisible}`);
console.log(` Overlay text: "${state?.overlayText}"`);
if (!state?.overlayVisible && !state?.overlayText) {
pass('Overlay cleared after Enter');
} else {
fail('Overlay not cleared after Enter', `visible=${state?.overlayVisible}, text="${state?.overlayText}"`);
}
// ── TEST 10: Rapid tab switching with pending text ──
console.log('\n--- Test 10: Rapid tab switching ---');
await page.keyboard.type('rapid-switch-text', { delay: 30 });
await page.waitForTimeout(200);
await screenshot(page, '11-before-rapid-switch');
// Rapid switch: A → B → A
await clickTab(page, sessionB);
await page.waitForTimeout(300);
await screenshot(page, '12-rapid-switch-to-b');
await clickTab(page, sessionA);
await page.waitForTimeout(300);
await screenshot(page, '13-rapid-switch-back-to-a');
state = await getOverlayState(page);
console.log(` After rapid switch — overlay text: "${state?.overlayText}"`);
console.log(` Flushed offsets: ${JSON.stringify(state?.flushedOffsets)}`);
// Text should have been flushed to PTY, overlay should be clear
if (!state?.overlayText || state?.overlayText === '') {
pass('Rapid switch: overlay clear after return');
} else {
fail('Rapid switch: overlay has stale text', `"${state?.overlayText}"`);
}
// ── TEST 11: Font detail check — pixel-level ──
console.log('\n--- Test 11: Detailed font comparison ---');
await page.keyboard.type('font-check', { delay: 50 });
await page.waitForTimeout(300);
const fontDetail = await page.evaluate(() => {
const app = window.app;
const overlay = app?._localEchoOverlay;
if (!overlay) return null;
// Overlay span
const oSpan = overlay.overlay.querySelector('span');
// Terminal character
const tRows = document.querySelector('.xterm-rows');
// Find a row with actual text
let tSpan = null;
if (tRows) {
const allSpans = tRows.querySelectorAll('span');
for (const s of allSpans) {
if (s.textContent?.trim()) { tSpan = s; break; }
}
}
const getDetailedFont = (el) => {
if (!el) return null;
const cs = getComputedStyle(el);
return {
fontFamily: cs.fontFamily,
fontSize: cs.fontSize,
fontWeight: cs.fontWeight,
fontStyle: cs.fontStyle,
letterSpacing: cs.letterSpacing,
lineHeight: cs.lineHeight,
color: cs.color,
textRendering: cs.textRendering,
webkitFontSmoothing: cs.webkitFontSmoothing || cs['-webkit-font-smoothing'],
fontKerning: cs.fontKerning,
fontVariantLigatures: cs.fontVariantLigatures,
};
};
// Also check xterm.js options
const xtermOpts = {
fontFamily: app.terminal?.options?.fontFamily,
fontSize: app.terminal?.options?.fontSize,
fontWeight: app.terminal?.options?.fontWeight,
letterSpacing: app.terminal?.options?.letterSpacing,
};
// Cell dimensions
let cellDims = null;
try {
const dims = app.terminal._core._renderService.dimensions;
cellDims = {
cellWidth: dims.css.cell.width,
cellHeight: dims.css.cell.height,
};
} catch {}
return {
overlaySpan: getDetailedFont(oSpan),
terminalSpan: getDetailedFont(tSpan),
terminalRows: getDetailedFont(tRows),
xtermOptions: xtermOpts,
cellDims,
// Check overlay cached values vs actual
overlayCached: {
fontFamily: overlay._fontFamily,
fontSize: overlay._fontSize,
fontWeight: overlay._fontWeight,
color: overlay._color,
letterSpacing: overlay._letterSpacing,
bg: overlay._bg,
},
};
});
await screenshot(page, '14-font-detail-check');
console.log(' xterm.js options:', JSON.stringify(fontDetail?.xtermOptions, null, 4));
console.log(' Cell dims:', JSON.stringify(fontDetail?.cellDims, null, 4));
console.log(' Overlay cached:', JSON.stringify(fontDetail?.overlayCached, null, 4));
console.log(' Overlay span computed:', JSON.stringify(fontDetail?.overlaySpan, null, 4));
console.log(' Terminal span computed:', JSON.stringify(fontDetail?.terminalSpan, null, 4));
console.log(' Terminal rows computed:', JSON.stringify(fontDetail?.terminalRows, null, 4));
// Check specific mismatches
if (fontDetail?.overlaySpan && fontDetail?.terminalSpan) {
const o = fontDetail.overlaySpan;
const t = fontDetail.terminalSpan;
const mismatches = [];
for (const key of ['fontFamily', 'fontSize', 'fontWeight', 'letterSpacing', 'fontKerning']) {
if (o[key] !== t[key]) {
mismatches.push(`${key}: overlay="${o[key]}" vs terminal="${t[key]}"`);
}
}
if (mismatches.length === 0) {
pass('Detailed font comparison matches');
} else {
fail('Font mismatches found', mismatches.join('; '));
}
} else {
info('Font detail check', 'Could not get computed styles for comparison');
}
// Clean up the typed text
await page.keyboard.press('Backspace');
await page.waitForTimeout(50);
for (let i = 0; i < 'font-check'.length; i++) {
await page.keyboard.press('Backspace');
await page.waitForTimeout(20);
}
// ── TEST 12: Long text that wraps ──
console.log('\n--- Test 12: Long text wrapping ---');
// Type enough to wrap past terminal width
const longText = 'A'.repeat(80) + 'B'.repeat(40); // 120 chars — likely wraps on mobile
await page.keyboard.type(longText, { delay: 5 });
await page.waitForTimeout(500);
state = await getOverlayState(page);
await screenshot(page, '15-long-text-wrap');
console.log(` Overlay text length: ${state?.overlayText?.length}`);
console.log(` Overlay children (lines): ${state?.overlayChildren?.length}`);
if (state?.overlayChildren) {
for (const [i, child] of state.overlayChildren.entries()) {
console.log(` Line ${i}: "${child.text}" (${child.spanCount} spans, left=${child.left}, top=${child.top})`);
}
}
if (state?.overlayText === longText) {
pass('Long text stored correctly');
} else {
fail('Long text storage', `expected ${longText.length} chars, got ${state?.overlayText?.length}`);
}
if (state?.overlayChildren && state.overlayChildren.length > 1) {
pass(`Long text wraps into ${state.overlayChildren.length} lines`);
} else {
fail('Long text wrapping', `only ${state?.overlayChildren?.length} line(s)`);
}
// Delete the long text
await page.keyboard.press('Enter');
await page.waitForTimeout(500);
// Final screenshot
await screenshot(page, '16-final-state');
// ── SUMMARY ──
console.log('\n=== SUMMARY ===');
const passes = results.filter(r => r.status === 'PASS').length;
const fails = results.filter(r => r.status === 'FAIL').length;
const infos = results.filter(r => r.status === 'INFO').length;
console.log(` ${passes} passed, ${fails} failed, ${infos} info`);
console.log(` Screenshots saved to ${SCREENSHOT_DIR}/`);
if (fails > 0) {
console.log('\n FAILURES:');
for (const r of results.filter(r => r.status === 'FAIL')) {
console.log(` ❌ ${r.name}: ${r.detail}`);
}
}
if (errors.length > 0) {
console.log(`\n Console errors (${errors.length}):`);
for (const e of errors.slice(0, 10)) console.log(` ${e}`);
}
} finally {
await browser.close();
console.log('\nCleaning up sessions...');
await deleteSession(sessionA);
await deleteSession(sessionB);
}
}
main().catch(err => {
console.error('Test failed:', err);
process.exit(1);
});
-398
View File
@@ -1,398 +0,0 @@
/**
* Local Echo Overlay — Scroll and position tests.
*
* Focused on:
* 1. Overlay hidden when scrolled up (prompt not at viewport bottom)
* 2. Overlay reappears when scrolled back to bottom
* 3. Overlay position correct after Ink redraws move the prompt
* 4. Overlay cursor block position matches text end
* 5. Multiple Enter submits — flushed offset doesn't carry across prompts
* 6. Backspace past all pending + all flushed = clean state
*
* Usage: node test/local-echo-scroll-test.mjs
*/
import { chromium } from 'playwright';
const PORT = 3000;
const BASE = `http://localhost:${PORT}`;
const SCREENSHOT_DIR = '/tmp/local-echo-scroll';
async function createSession(name) {
const resp = await fetch(`${BASE}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'claude', name })
});
if (!resp.ok) throw new Error(`Failed to create session '${name}': ${resp.status}`);
const data = await resp.json();
return data.session?.id || data.id;
}
async function deleteSession(id) {
await fetch(`${BASE}/api/sessions/${id}`, { method: 'DELETE' }).catch(() => {});
}
async function waitForReady(sessionId, timeoutMs = 15000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const resp = await fetch(`${BASE}/api/sessions/${sessionId}`);
if (resp.ok) {
const data = await resp.json();
if (data.status === 'idle' || data.status === 'busy') return true;
}
await new Promise(r => setTimeout(r, 300));
}
return false;
}
async function getState(page) {
return page.evaluate(() => {
const app = window.app;
if (!app) return null;
const overlay = app._localEchoOverlay;
const buf = app.terminal?.buffer?.active;
return {
overlayVisible: overlay?.overlay?.style.display !== 'none',
overlayText: overlay?.pendingText || '',
flushedOffset: overlay?._flushedOffset || 0,
flushedOffsets: app._flushedOffsets ? Object.fromEntries(app._flushedOffsets) : {},
localEchoEnabled: app._localEchoEnabled,
activeSessionId: app.activeSessionId,
// Scroll state
viewportY: buf?.viewportY,
baseY: buf?.baseY,
isAtBottom: buf ? buf.viewportY === buf.baseY : null,
terminalRows: app.terminal?.rows,
// Overlay children
overlayChildren: (() => {
if (!overlay?.overlay) return [];
return Array.from(overlay.overlay.children).map(c => ({
text: Array.from(c.querySelectorAll('span')).map(s => s.textContent).join(''),
left: c.style.left,
top: c.style.top,
}));
})(),
};
});
}
async function focusTerminal(page) {
await page.evaluate(() => { document.querySelector('.xterm-helper-textarea')?.focus(); });
await page.waitForTimeout(200);
}
async function screenshot(page, name) {
const path = `${SCREENSHOT_DIR}/${name}.png`;
await page.screenshot({ path, fullPage: false });
console.log(` 📸 ${path}`);
}
const results = [];
function pass(name) { results.push({ name, status: 'PASS' }); console.log(` ✅ PASS: ${name}`); }
function fail(name, detail) { results.push({ name, status: 'FAIL', detail }); console.log(` ❌ FAIL: ${name} — ${detail}`); }
function info(name, detail) { results.push({ name, status: 'INFO', detail }); console.log(` ℹ️ INFO: ${name} — ${detail}`); }
async function main() {
const { mkdirSync } = await import('fs');
try { mkdirSync(SCREENSHOT_DIR, { recursive: true }); } catch {}
console.log('=== Local Echo — Scroll & Position Tests ===\n');
const sessionA = await createSession('scroll-test-A');
const sessionB = await createSession('scroll-test-B');
console.log(` Session A: ${sessionA}`);
console.log(` Session B: ${sessionB}`);
if (!await waitForReady(sessionA) || !await waitForReady(sessionB)) {
console.error('Sessions failed to become ready');
await deleteSession(sessionA);
await deleteSession(sessionB);
process.exit(1);
}
console.log(' Ready\n');
const browser = await chromium.launch({ headless: true, args: ['--window-size=390,844'] });
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)',
isMobile: true, hasTouch: true,
});
const page = await context.newPage();
try {
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.xterm-screen', { timeout: 10000 });
await page.waitForTimeout(2000);
// Enable local echo
await page.evaluate(() => {
const s = JSON.parse(localStorage.getItem('codeman-app-settings') || '{}');
s.localEchoEnabled = true;
localStorage.setItem('codeman-app-settings', JSON.stringify(s));
window.app?._updateLocalEchoState?.();
});
// Select session A
await page.evaluate((id) => {
const t = document.querySelector(`.session-tab[data-id="${id}"]`);
if (t) t.click();
}, sessionA);
await page.waitForTimeout(1500);
await focusTerminal(page);
// ══════════════════════════════════════════
// TEST 1: Overlay hidden when scrolled up
// ══════════════════════════════════════════
console.log('--- Test 1: Overlay hidden when scrolled up ---');
// Generate some content so we can scroll
await page.keyboard.type('just say ok', { delay: 20 });
await page.keyboard.press('Enter');
await page.waitForTimeout(4000); // Wait for response
await focusTerminal(page);
await page.keyboard.type('test-scroll-text', { delay: 30 });
await page.waitForTimeout(300);
let state = await getState(page);
console.log(` At bottom: isAtBottom=${state?.isAtBottom}, viewportY=${state?.viewportY}, baseY=${state?.baseY}`);
await screenshot(page, '01-at-bottom-with-text');
if (state?.overlayVisible && state?.overlayText === 'test-scroll-text') {
pass('Overlay visible at bottom with text');
} else {
fail('Overlay at bottom', `visible=${state?.overlayVisible}, text="${state?.overlayText}"`);
}
// Scroll to top
await page.evaluate(() => { window.app?.terminal?.scrollToTop(); });
await page.waitForTimeout(300);
state = await getState(page);
console.log(` After scroll up: isAtBottom=${state?.isAtBottom}, viewportY=${state?.viewportY}, baseY=${state?.baseY}`);
console.log(` Overlay visible: ${state?.overlayVisible}, text: "${state?.overlayText}"`);
await screenshot(page, '02-scrolled-up');
if (state?.isAtBottom) {
// Buffer too short to scroll — skip this check
info('Overlay scroll-up test skipped', 'buffer too short to scroll (viewportY===baseY)');
} else if (!state?.overlayVisible) {
pass('Overlay hidden when scrolled up');
} else {
fail('Overlay visible when scrolled up', `visible=${state?.overlayVisible}`);
}
// Text should be preserved even though overlay is hidden
if (state?.overlayText === 'test-scroll-text') {
pass('Overlay text preserved while hidden');
} else {
fail('Overlay text lost during scroll', `"${state?.overlayText}"`);
}
// ══════════════════════════════════════════
// TEST 2: Overlay reappears at bottom
// ══════════════════════════════════════════
console.log('\n--- Test 2: Overlay reappears when scrolled back ---');
await page.evaluate(() => { window.app?.terminal?.scrollToBottom(); });
await page.waitForTimeout(300);
// Type one more char to trigger re-render
await page.keyboard.type('!', { delay: 30 });
await page.waitForTimeout(300);
state = await getState(page);
console.log(` At bottom: isAtBottom=${state?.isAtBottom}`);
console.log(` Overlay visible: ${state?.overlayVisible}, text: "${state?.overlayText}"`);
await screenshot(page, '03-back-to-bottom');
if (state?.overlayVisible && state?.overlayText === 'test-scroll-text!') {
pass('Overlay reappears at bottom with preserved text');
} else {
info('Overlay after scroll back', `visible=${state?.overlayVisible}, text="${state?.overlayText}"`);
}
// Clear
await page.keyboard.press('Control+c');
await page.waitForTimeout(500);
// ══════════════════════════════════════════
// TEST 3: Backspace deletes ALL flushed text back to zero
// ══════════════════════════════════════════
console.log('\n--- Test 3: Full backspace through pending + flushed ---');
await focusTerminal(page);
await page.keyboard.type('abcdef', { delay: 20 }); // 6 chars pending
await page.waitForTimeout(200);
// Flush by tab switch
await page.evaluate((id) => {
document.querySelector(`.session-tab[data-id="${id}"]`)?.click();
}, sessionB);
await page.waitForTimeout(1000);
// Switch back
await page.evaluate((id) => {
document.querySelector(`.session-tab[data-id="${id}"]`)?.click();
}, sessionA);
await page.waitForTimeout(1000);
await focusTerminal(page);
// Type 3 more chars
await page.keyboard.type('xyz', { delay: 20 });
await page.waitForTimeout(200);
state = await getState(page);
console.log(` State: pending="${state?.overlayText}", flushed=${state?.flushedOffset}`);
// Now backspace 3 (pending "xyz") + 6 (flushed "abcdef") + 2 extra = 11
for (let i = 0; i < 11; i++) {
await page.keyboard.press('Backspace');
await page.waitForTimeout(40);
}
await page.waitForTimeout(300);
state = await getState(page);
console.log(` After full delete: pending="${state?.overlayText}", flushed=${state?.flushedOffset}, map=${JSON.stringify(state?.flushedOffsets)}`);
await screenshot(page, '04-full-backspace');
if (state?.flushedOffset === 0 && !state?.overlayText) {
pass('Full backspace clears both pending and flushed');
} else {
fail('Full backspace', `pending="${state?.overlayText}", flushed=${state?.flushedOffset}`);
}
if (!state?.flushedOffsets?.[sessionA]) {
pass('Flushed offsets Map cleared after full backspace');
} else {
fail('Map not cleared', JSON.stringify(state?.flushedOffsets));
}
// ══════════════════════════════════════════
// TEST 4: Type → Enter → type again — no stale offset
// ══════════════════════════════════════════
console.log('\n--- Test 4: Enter resets state cleanly ---');
await focusTerminal(page);
await page.keyboard.type('first-prompt', { delay: 20 });
await page.waitForTimeout(200);
await page.keyboard.press('Enter');
await page.waitForTimeout(2000); // Wait for Claude response
// Now type on the NEW prompt
await focusTerminal(page);
await page.keyboard.type('second-prompt', { delay: 20 });
await page.waitForTimeout(300);
state = await getState(page);
console.log(` After Enter+retype: pending="${state?.overlayText}", flushed=${state?.flushedOffset}`);
await screenshot(page, '05-after-enter-retype');
if (state?.overlayText === 'second-prompt' && state?.flushedOffset === 0) {
pass('Clean state after Enter + retype');
} else {
fail('Stale state after Enter', `pending="${state?.overlayText}", flushed=${state?.flushedOffset}`);
}
// Clean up
await page.keyboard.press('Control+c');
await page.waitForTimeout(500);
// ══════════════════════════════════════════
// TEST 5: Overlay cursor position matches text end
// ══════════════════════════════════════════
console.log('\n--- Test 5: Cursor block position ---');
await focusTerminal(page);
await page.keyboard.type('cursor-pos', { delay: 30 }); // 10 chars
await page.waitForTimeout(300);
state = await getState(page);
const children = state?.overlayChildren || [];
console.log(` Children: ${JSON.stringify(children)}`);
// Should have 2 children: line div + cursor span
// Cursor should be at position (startCol + 10) * cellW
if (children.length >= 2) {
const cursorChild = children[children.length - 1];
// Cursor text should be empty (it's a block cursor, not a character)
if (cursorChild.text === '') {
pass('Cursor block present at end of text');
} else {
fail('Cursor block', `expected empty text, got "${cursorChild.text}"`);
}
// Check cursor is right after the text
const textChild = children[0];
const textLeft = parseInt(textChild.left);
const cellW = 6; // from previous tests
const expectedCursorLeft = textLeft + 10 * cellW; // 10 chars of "cursor-pos"
const actualCursorLeft = parseInt(cursorChild.left);
console.log(` Text left: ${textLeft}px, cursor left: ${actualCursorLeft}px, expected: ${expectedCursorLeft}px`);
if (actualCursorLeft === expectedCursorLeft) {
pass('Cursor positioned correctly after text');
} else {
fail('Cursor position', `expected ${expectedCursorLeft}px, got ${actualCursorLeft}px`);
}
} else {
fail('Overlay children count', `expected >=2, got ${children.length}`);
}
await screenshot(page, '06-cursor-position');
// Clean up
await page.keyboard.press('Control+c');
await page.waitForTimeout(500);
// ══════════════════════════════════════════
// TEST 6: Type while Claude is busy (working status)
// ══════════════════════════════════════════
console.log('\n--- Test 6: Type while Claude is busy ---');
await focusTerminal(page);
await page.keyboard.type('generate a long response with lots of text', { delay: 10 });
await page.keyboard.press('Enter');
await page.waitForTimeout(1000); // Claude starts responding
// Type while Claude is outputting
await page.keyboard.type('typing-while-busy', { delay: 30 });
await page.waitForTimeout(300);
state = await getState(page);
console.log(` While busy: pending="${state?.overlayText}", visible=${state?.overlayVisible}`);
await screenshot(page, '07-typing-while-busy');
if (state?.overlayText === 'typing-while-busy') {
pass('Can type while Claude is busy');
} else {
info('Typing while busy', `"${state?.overlayText}"`);
}
// Clean up
await page.keyboard.press('Control+c');
await page.waitForTimeout(500);
// Final screenshot
await screenshot(page, '08-final');
// ══════════════════════════════════════════
// SUMMARY
// ══════════════════════════════════════════
console.log('\n=== SUMMARY ===');
const passes = results.filter(r => r.status === 'PASS').length;
const fails = results.filter(r => r.status === 'FAIL').length;
const infos = results.filter(r => r.status === 'INFO').length;
console.log(` ${passes} passed, ${fails} failed, ${infos} info`);
if (fails > 0) {
console.log('\n FAILURES:');
for (const r of results.filter(r => r.status === 'FAIL')) {
console.log(` ❌ ${r.name}: ${r.detail}`);
}
}
} finally {
await browser.close();
console.log('\nCleaning up...');
await deleteSession(sessionA);
await deleteSession(sessionB);
}
}
main().catch(err => {
console.error('Test failed:', err);
process.exit(1);
});
-516
View File
@@ -1,516 +0,0 @@
/**
* Local Echo Overlay — Tab Switch Bug Reproduction Test
*
* Reproduces the specific bug: switch tabs, switch back, type a space →
* previous input disappears, cursor moves to start.
*
* Port: 3000 (runs against live dev server)
*
* Usage: node test/local-echo-tabswitch-test.mjs
*/
import { chromium } from 'playwright';
import { mkdirSync } from 'fs';
const PORT = 3000;
const BASE = `http://localhost:${PORT}`;
const SCREENSHOT_DIR = '/tmp/local-echo-tabswitch';
const results = [];
const jsErrors = [];
function pass(name) { results.push({ name, ok: true }); console.log(` ✓ PASS: ${name}`); }
function fail(name, detail) { results.push({ name, ok: false, detail }); console.log(` ✗ FAIL: ${name} — ${detail}`); }
async function createSession(name) {
const resp = await fetch(`${BASE}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'shell', name })
});
if (!resp.ok) throw new Error(`Failed to create session '${name}': ${resp.status}`);
const data = await resp.json();
return data.session?.id || data.id;
}
async function deleteSession(id) {
await fetch(`${BASE}/api/sessions/${id}`, { method: 'DELETE' }).catch(() => {});
}
async function waitForReady(sessionId, timeoutMs = 15000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const resp = await fetch(`${BASE}/api/sessions/${sessionId}`);
if (resp.ok) {
const data = await resp.json();
if (data.status === 'idle' || data.status === 'busy') return true;
}
await new Promise(r => setTimeout(r, 200));
}
return false;
}
async function getOverlayState(page) {
return page.evaluate(() => {
const app = window.app;
if (!app) return null;
const overlay = app._localEchoOverlay;
const overlayEl = overlay?.overlay;
// Get displayed text from overlay DOM children
let displayedText = '';
if (overlayEl) {
for (const child of overlayEl.children) {
// Skip cursor element (no inner spans with text or single span with no text)
const spans = child.querySelectorAll('span');
const text = Array.from(spans).map(s => s.textContent).join('');
displayedText += text;
}
}
return {
overlayVisible: overlayEl ? overlayEl.style.display !== 'none' : false,
pendingText: overlay?.pendingText || '',
flushedOffset: overlay?._flushedOffset || 0,
flushedText: overlay?._flushedText || '',
displayedText: displayedText.replace(/\u00a0/g, ' '), // normalize nbsp
localEchoEnabled: app._localEchoEnabled,
activeSessionId: app.activeSessionId,
flushedOffsetsMap: app._flushedOffsets ? Object.fromEntries(app._flushedOffsets) : {},
flushedTextsMap: app._flushedTexts ? Object.fromEntries(app._flushedTexts) : {},
lastPromptPos: overlay?._lastPromptPos || null,
overlayChildCount: overlayEl?.children?.length || 0,
};
});
}
async function screenshot(page, name) {
const path = `${SCREENSHOT_DIR}/${name}.png`;
await page.screenshot({ path, fullPage: false });
console.log(` 📸 ${path}`);
}
async function clickTab(page, sessionId) {
await page.evaluate((id) => {
const tab = document.querySelector(`.session-tab[data-id="${id}"]`);
if (tab) tab.click();
}, sessionId);
}
async function focusTerminal(page) {
await page.evaluate(() => {
document.querySelector('.xterm-helper-textarea')?.focus();
});
await page.waitForTimeout(200);
}
async function main() {
try { mkdirSync(SCREENSHOT_DIR, { recursive: true }); } catch {}
console.log('=== Local Echo Tab Switch Bug Test ===\n');
// Create two shell sessions
console.log('Creating test sessions...');
const sessionA = await createSession('ts-test-A');
const sessionB = await createSession('ts-test-B');
console.log(` Session A: ${sessionA}`);
console.log(` Session B: ${sessionB}`);
const readyA = await waitForReady(sessionA);
const readyB = await waitForReady(sessionB);
if (!readyA || !readyB) {
console.error('Sessions failed to become ready');
await deleteSession(sessionA);
await deleteSession(sessionB);
process.exit(1);
}
console.log(' Both sessions ready\n');
const browser = await chromium.launch({
headless: true,
args: ['--window-size=390,844']
});
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15',
isMobile: true,
hasTouch: true,
});
const page = await context.newPage();
page.on('console', msg => { if (msg.type() === 'error') jsErrors.push(msg.text()); });
// Install instrumentation to trace flushed state clearing
await page.exposeFunction('__traceFlushedClear', (source, data) => {
console.log(` 🔍 TRACE: flushed state cleared from "${source}" — data=${data}`);
});
try {
// ── SETUP ──
console.log('Opening Codeman...');
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.xterm-screen', { timeout: 10000 });
await page.waitForTimeout(2500);
// Enable local echo
console.log('Enabling local echo...');
await page.evaluate(() => {
const key = window.app?.getSettingsStorageKey?.() || 'codeman-app-settings';
const settings = JSON.parse(localStorage.getItem(key) || '{}');
settings.localEchoEnabled = true;
localStorage.setItem(key, JSON.stringify(settings));
if (window.app?._cachedAppSettings) window.app._cachedAppSettings.localEchoEnabled = true;
window.app?._updateLocalEchoState?.();
});
await page.waitForTimeout(300);
// Install tracing hooks inside the page to catch when flushed state is cleared
await page.evaluate(() => {
const app = window.app;
if (!app) return;
// Patch the onData handler to trace control char clearing
// We can't directly patch the inner function, but we can watch the Maps
const origDelete = Map.prototype.delete;
const flushedOffsets = app._flushedOffsets;
const flushedTexts = app._flushedTexts;
// Trace overlay.clear()
if (app._localEchoOverlay) {
const origClear = app._localEchoOverlay.clear.bind(app._localEchoOverlay);
app._localEchoOverlay.clear = function() {
const stack = new Error().stack;
// Extract caller info
const callerLine = stack.split('\n')[2]?.trim() || 'unknown';
console.warn(`[TRACE] overlay.clear() called from: ${callerLine}`);
window.__localEchoClearLog = window.__localEchoClearLog || [];
window.__localEchoClearLog.push({
time: Date.now(),
source: 'overlay.clear()',
caller: callerLine,
hadFlushed: this._flushedOffset > 0,
hadPending: this.pendingText.length > 0,
});
return origClear();
};
}
});
// Switch to session A
console.log('Switching to Session A...');
await clickTab(page, sessionA);
await page.waitForTimeout(2000);
await focusTerminal(page);
let state = await getOverlayState(page);
console.log(` Local echo enabled: ${state?.localEchoEnabled}`);
if (!state?.localEchoEnabled) {
fail('Local echo not enabled', `localEchoEnabled=${state?.localEchoEnabled}`);
return;
}
pass('Local echo enabled');
// ── STEP 1: Type text in Session A ──
console.log('\n--- Step 1: Type "hello world" in Session A ---');
await page.keyboard.type('hello world', { delay: 50 });
await page.waitForTimeout(500);
state = await getOverlayState(page);
await screenshot(page, '01-typed-in-A');
console.log(` pendingText: "${state?.pendingText}"`);
console.log(` flushedOffset: ${state?.flushedOffset}`);
console.log(` overlayVisible: ${state?.overlayVisible}`);
console.log(` displayedText: "${state?.displayedText}"`);
if (state?.pendingText === 'hello world') {
pass('Text typed in Session A');
} else {
fail('Text in Session A', `expected "hello world", got "${state?.pendingText}"`);
}
// ── STEP 2: Switch to Session B ──
console.log('\n--- Step 2: Switch to Session B ---');
// Clear trace log
await page.evaluate(() => { window.__localEchoClearLog = []; });
await clickTab(page, sessionB);
await page.waitForTimeout(2500);
await focusTerminal(page);
state = await getOverlayState(page);
await screenshot(page, '02-switched-to-B');
console.log(` activeSession: ${state?.activeSessionId}`);
console.log(` pendingText: "${state?.pendingText}"`);
console.log(` flushedOffset: ${state?.flushedOffset}`);
console.log(` flushedOffsetsMap: ${JSON.stringify(state?.flushedOffsetsMap)}`);
console.log(` flushedTextsMap: ${JSON.stringify(state?.flushedTextsMap)}`);
if (state?.activeSessionId === sessionB) {
pass('Switched to Session B');
} else {
fail('Tab switch to B', `activeSession=${state?.activeSessionId}`);
}
// Check that A's text was stored in flushed maps
const flushedForA = state?.flushedOffsetsMap?.[sessionA];
if (flushedForA === 11) { // "hello world" = 11 chars
pass(`Session A text flushed (${flushedForA} chars)`);
} else {
fail('Flushed offset for A', `expected 11, got ${flushedForA}`);
}
// ── STEP 3: Type in Session B ──
console.log('\n--- Step 3: Type "typing in B" in Session B ---');
await page.keyboard.type('typing in B', { delay: 50 });
await page.waitForTimeout(500);
state = await getOverlayState(page);
await screenshot(page, '03-typed-in-B');
console.log(` pendingText: "${state?.pendingText}"`);
console.log(` flushedOffsetsMap: ${JSON.stringify(state?.flushedOffsetsMap)}`);
if (state?.pendingText === 'typing in B') {
pass('Text typed in Session B');
} else {
fail('Text in Session B', `expected "typing in B", got "${state?.pendingText}"`);
}
// Verify A's flushed state is still in the Maps
if (state?.flushedOffsetsMap?.[sessionA] === 11) {
pass('Session A flushed state preserved while on B');
} else {
fail('A flushed state while on B', `expected 11, got ${state?.flushedOffsetsMap?.[sessionA]}`);
}
// ── STEP 4: Switch back to Session A ──
console.log('\n--- Step 4: Switch back to Session A ---');
await page.evaluate(() => { window.__localEchoClearLog = []; });
await clickTab(page, sessionA);
await page.waitForTimeout(3000); // Wait for full buffer load
state = await getOverlayState(page);
await screenshot(page, '04-back-to-A-before-typing');
console.log(` activeSession: ${state?.activeSessionId}`);
console.log(` pendingText: "${state?.pendingText}"`);
console.log(` flushedOffset: ${state?.flushedOffset}`);
console.log(` flushedText: "${state?.flushedText}"`);
console.log(` flushedOffsetsMap: ${JSON.stringify(state?.flushedOffsetsMap)}`);
console.log(` overlayVisible: ${state?.overlayVisible}`);
console.log(` lastPromptPos: ${JSON.stringify(state?.lastPromptPos)}`);
console.log(` displayedText: "${state?.displayedText}"`);
// Check trace log
const clearLog = await page.evaluate(() => window.__localEchoClearLog || []);
console.log(` Clear log entries: ${clearLog.length}`);
for (const entry of clearLog) {
console.log(` - ${entry.source} @ ${entry.caller} (hadFlushed=${entry.hadFlushed}, hadPending=${entry.hadPending})`);
}
if (state?.activeSessionId === sessionA) {
pass('Switched back to Session A');
} else {
fail('Tab switch back to A', `activeSession=${state?.activeSessionId}`);
}
// KEY CHECK: flushed state should be restored
if (state?.flushedOffset === 11) {
pass('Flushed offset restored on overlay');
} else {
fail('Flushed offset on overlay', `expected 11, got ${state?.flushedOffset}`);
}
if (state?.flushedText === 'hello world') {
pass('Flushed text restored on overlay');
} else {
fail('Flushed text on overlay', `expected "hello world", got "${state?.flushedText}"`);
}
// ── STEP 5: THE BUG — Type a space after switching back ──
console.log('\n--- Step 5: Type a SPACE after returning to Session A ---');
await focusTerminal(page);
// Record state RIGHT BEFORE typing
const preTypeState = await getOverlayState(page);
console.log(` PRE-TYPE state:`);
console.log(` pendingText: "${preTypeState?.pendingText}"`);
console.log(` flushedOffset: ${preTypeState?.flushedOffset}`);
console.log(` flushedText: "${preTypeState?.flushedText}"`);
console.log(` overlayVisible: ${preTypeState?.overlayVisible}`);
console.log(` localEchoEnabled: ${preTypeState?.localEchoEnabled}`);
await page.keyboard.type(' ', { delay: 0 }); // Type single space
await page.waitForTimeout(500);
state = await getOverlayState(page);
await screenshot(page, '05-typed-space-after-return');
console.log(` POST-TYPE state:`);
console.log(` pendingText: "${state?.pendingText}"`);
console.log(` flushedOffset: ${state?.flushedOffset}`);
console.log(` flushedText: "${state?.flushedText}"`);
console.log(` overlayVisible: ${state?.overlayVisible}`);
console.log(` displayedText: "${state?.displayedText}"`);
console.log(` overlayChildCount: ${state?.overlayChildCount}`);
// THE CRITICAL ASSERTION: flushed text should still be tracked
if (state?.flushedOffset === 11) {
pass('Flushed offset preserved after typing space');
} else {
fail('Flushed offset after space', `expected 11, got ${state?.flushedOffset}`);
}
// The displayed text should include BOTH flushed text AND new space
const expectedDisplay = 'hello world ';
if (state?.displayedText?.startsWith('hello world')) {
pass('Displayed text includes flushed + new text');
} else {
fail('Displayed text', `expected starts with "hello world", got "${state?.displayedText}"`);
}
if (state?.overlayVisible) {
pass('Overlay visible after typing');
} else {
fail('Overlay visibility', 'overlay is hidden after typing');
}
// ── STEP 6: Type more characters ──
console.log('\n--- Step 6: Type "more" after the space ---');
await page.keyboard.type('more', { delay: 50 });
await page.waitForTimeout(500);
state = await getOverlayState(page);
await screenshot(page, '06-typed-more-after-space');
console.log(` pendingText: "${state?.pendingText}"`);
console.log(` flushedOffset: ${state?.flushedOffset}`);
console.log(` displayedText: "${state?.displayedText}"`);
if (state?.pendingText === ' more') {
pass('Pending text is " more"');
} else {
fail('Pending text after more typing', `expected " more", got "${state?.pendingText}"`);
}
const expectedFullDisplay = 'hello world more';
if (state?.displayedText?.includes('hello world') && state?.displayedText?.includes('more')) {
pass('Full displayed text includes flushed + all new text');
} else {
fail('Full display', `expected "${expectedFullDisplay}", got "${state?.displayedText}"`);
}
// ── STEP 7: Backspace through new text into flushed text ──
console.log('\n--- Step 7: Backspace through new and into flushed text ---');
// Delete " more" (5 chars) + 3 from flushed
for (let i = 0; i < 8; i++) {
await page.keyboard.press('Backspace');
await page.waitForTimeout(50);
}
await page.waitForTimeout(300);
state = await getOverlayState(page);
await screenshot(page, '07-after-backspace-into-flushed');
console.log(` pendingText: "${state?.pendingText}"`);
console.log(` flushedOffset: ${state?.flushedOffset}`);
console.log(` flushedText: "${state?.flushedText}"`);
console.log(` displayedText: "${state?.displayedText}"`);
// Should have deleted 5 pending + 3 flushed = flushedOffset 8
if (state?.pendingText === '') {
pass('Pending text empty after backspace');
} else {
fail('Pending text after backspace', `expected "", got "${state?.pendingText}"`);
}
if (state?.flushedOffset === 8) {
pass('Flushed offset correctly decremented (11 - 3 = 8)');
} else {
fail('Flushed offset after backspace', `expected 8, got ${state?.flushedOffset}`);
}
// ── STEP 8: Submit with Enter ──
console.log('\n--- Step 8: Press Enter to submit ---');
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
state = await getOverlayState(page);
await screenshot(page, '08-after-enter');
console.log(` pendingText: "${state?.pendingText}"`);
console.log(` flushedOffset: ${state?.flushedOffset}`);
console.log(` overlayVisible: ${state?.overlayVisible}`);
console.log(` flushedOffsetsMap: ${JSON.stringify(state?.flushedOffsetsMap)}`);
if (!state?.overlayVisible && state?.pendingText === '' && state?.flushedOffset === 0) {
pass('Overlay fully cleared after Enter');
} else {
fail('State after Enter', `visible=${state?.overlayVisible}, pending="${state?.pendingText}", flushed=${state?.flushedOffset}`);
}
if (!state?.flushedOffsetsMap?.[sessionA]) {
pass('Flushed map cleared for session A after Enter');
} else {
fail('Flushed map after Enter', `still has A: ${state?.flushedOffsetsMap?.[sessionA]}`);
}
// ── STEP 9: Repeat the cycle — ensure it works multiple times ──
console.log('\n--- Step 9: Second cycle — type, switch, switch back, type ---');
await page.keyboard.type('second-round', { delay: 40 });
await page.waitForTimeout(300);
// Switch to B
await clickTab(page, sessionB);
await page.waitForTimeout(2000);
// Switch back to A
await clickTab(page, sessionA);
await page.waitForTimeout(3000);
await focusTerminal(page);
state = await getOverlayState(page);
console.log(` After 2nd cycle — flushedOffset: ${state?.flushedOffset}, flushedText: "${state?.flushedText}"`);
// Type space + more text
await page.keyboard.type(' again', { delay: 40 });
await page.waitForTimeout(500);
state = await getOverlayState(page);
await screenshot(page, '09-second-cycle');
console.log(` pendingText: "${state?.pendingText}"`);
console.log(` flushedOffset: ${state?.flushedOffset}`);
console.log(` displayedText: "${state?.displayedText}"`);
if (state?.displayedText?.includes('second-round') && state?.displayedText?.includes('again')) {
pass('Second cycle: flushed + new text displayed correctly');
} else {
fail('Second cycle display', `expected "second-round again", got "${state?.displayedText}"`);
}
// ── SUMMARY ──
console.log('\n=== SUMMARY ===');
const passes = results.filter(r => r.ok).length;
const fails = results.filter(r => !r.ok).length;
console.log(` ${passes} passed, ${fails} failed`);
console.log(` Screenshots: ${SCREENSHOT_DIR}/`);
if (fails > 0) {
console.log('\n FAILURES:');
for (const r of results.filter(r => !r.ok)) {
console.log(` ✗ ${r.name}: ${r.detail}`);
}
}
if (jsErrors.length > 0) {
console.log(`\n JS errors (${jsErrors.length}):`);
for (const e of jsErrors.slice(0, 10)) console.log(` ${e}`);
}
console.log(`\n Exit code: ${fails > 0 ? 1 : 0}`);
process.exitCode = fails > 0 ? 1 : 0;
} finally {
await browser.close();
console.log('\nCleaning up sessions...');
await deleteSession(sessionA);
await deleteSession(sessionB);
}
}
main().catch(err => {
console.error('Test failed:', err);
process.exit(1);
});
File diff suppressed because it is too large Load Diff
-67
View File
@@ -1,67 +0,0 @@
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(6000);
await page.locator('.session-tab').first().click();
await page.waitForTimeout(4000);
// Check ALL background colors in the xterm rendering stack
const bgInfo = await page.evaluate(() => {
const term = window.app?.terminal;
if (!term) return { error: 'no terminal' };
const el = term.element;
if (!el) return { error: 'no element' };
const results = {};
// Walk up from xterm-screen to find who provides the background
const screen = el.querySelector('.xterm-screen');
const viewport = el.querySelector('.xterm-viewport');
const rows = el.querySelector('.xterm-rows');
const elements = {
'.xterm (term.element)': el,
'.xterm-viewport': viewport,
'.xterm-screen': screen,
'.xterm-rows': rows,
};
// Also check parent elements
let parent = el.parentElement;
let depth = 0;
while (parent && depth < 5) {
elements[`parent-${depth} (${parent.tagName}.${parent.className?.split(' ')[0] || ''})`] = parent;
parent = parent.parentElement;
depth++;
}
for (const [name, elem] of Object.entries(elements)) {
if (!elem) { results[name] = 'not found'; continue; }
const cs = getComputedStyle(elem);
results[name] = {
background: cs.background?.slice(0, 80),
backgroundColor: cs.backgroundColor,
inlineStyle: elem.style.backgroundColor || elem.style.background || '(none)',
};
}
// Also check if xterm-viewport has inline style set by xterm.js
if (viewport) {
results['viewport-inline-full'] = viewport.style.cssText?.slice(0, 200);
}
// Theme config
results.theme = term.options?.theme;
// Overlay bg
results.overlayBg = window.app?._localEchoOverlay?._font?.backgroundColor;
return results;
});
console.log(JSON.stringify(bgInfo, null, 2));
await browser.close();
-85
View File
@@ -1,85 +0,0 @@
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(6000);
await page.locator('.session-tab').first().click();
await page.waitForTimeout(4000);
// Sample the ACTUAL canvas pixel color at an empty area
const colorInfo = await page.evaluate(() => {
const term = window.app?.terminal;
if (!term) return { error: 'no terminal' };
// Find the WebGL canvas
const screen = term.element?.querySelector('.xterm-screen');
const canvases = screen?.querySelectorAll('canvas');
const results = {};
for (const canvas of canvases || []) {
const ctx = canvas.getContext('2d') || canvas.getContext('webgl') || canvas.getContext('webgl2');
const ctxType = ctx?.constructor?.name;
if (ctx && (ctxType === 'CanvasRenderingContext2D')) {
// 2D context — can read pixels directly
try {
const pixel = ctx.getImageData(10, 10, 1, 1).data;
results['2d'] = {
r: pixel[0], g: pixel[1], b: pixel[2], a: pixel[3],
hex: '#' + [pixel[0], pixel[1], pixel[2]].map(v => v.toString(16).padStart(2, '0')).join(''),
};
} catch (e) {
results['2d'] = { error: e.message };
}
} else if (ctx) {
// WebGL context — use readPixels
try {
const gl = ctx;
const pixel = new Uint8Array(4);
// Read from an empty area (bottom-left corner, row 0 col 0)
gl.readPixels(10, canvas.height - 10, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel);
results['webgl'] = {
r: pixel[0], g: pixel[1], b: pixel[2], a: pixel[3],
hex: '#' + [pixel[0], pixel[1], pixel[2]].map(v => v.toString(16).padStart(2, '0')).join(''),
};
} catch (e) {
results['webgl'] = { error: e.message };
}
}
}
// Also check what the terminal theme says
results.theme = {
background: term.options?.theme?.background,
foreground: term.options?.theme?.foreground,
};
// Check the overlay's configured background
const overlay = window.app?._localEchoOverlay;
results.overlayBg = overlay?._font?.backgroundColor;
// Check xterm-rows computed style
const rows = term.element?.querySelector('.xterm-rows');
if (rows) {
results.rowsBg = getComputedStyle(rows).backgroundColor;
}
// Check .xterm element bg
const xtermEl = term.element;
if (xtermEl) {
results.xtermBg = getComputedStyle(xtermEl).backgroundColor;
}
// Canvas count and types
results.canvasCount = canvases?.length;
results.canvasClasses = Array.from(canvases || []).map(c => c.className);
return results;
});
console.log(JSON.stringify(colorInfo, null, 2));
await browser.close();
console.log('Done');
-48
View File
@@ -1,48 +0,0 @@
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(6000);
const firstTab = page.locator('.session-tab').first();
await firstTab.click();
await page.waitForTimeout(5000);
const info = await page.evaluate(() => {
const term = window.app?.terminal;
if (!term) return { error: 'no app.terminal' };
const core = term._core;
const renderService = core?._renderService;
const dims = renderService?.dimensions;
if (!dims) return { error: 'no dimensions', hasCore: !!core, hasRenderService: !!renderService };
return {
css: {
cell: dims.css?.cell,
char: dims.css?.char,
canvas: dims.css?.canvas,
},
device: {
cell: dims.device?.cell,
char: dims.device?.char,
canvas: dims.device?.canvas,
},
topLevelKeys: Object.keys(dims),
cssKeys: dims.css ? Object.keys(dims.css) : [],
deviceKeys: dims.device ? Object.keys(dims.device) : [],
cssAllDetail: dims.css ? Object.fromEntries(
Object.entries(dims.css).map(([k, v]) => [k, v && typeof v === 'object' ? { ...v } : v])
) : null,
deviceAllDetail: dims.device ? Object.fromEntries(
Object.entries(dims.device).map(([k, v]) => [k, v && typeof v === 'object' ? { ...v } : v])
) : null,
dpr: window.devicePixelRatio,
};
});
console.log(JSON.stringify(info, null, 2));
await browser.close();
-87
View File
@@ -1,87 +0,0 @@
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true, deviceScaleFactor: 2 });
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(6000);
await page.locator('.session-tab').first().click();
await page.waitForTimeout(4000);
await page.evaluate(() => {
const settings = JSON.parse(localStorage.getItem('codeman-settings') || '{}');
settings.localEchoEnabled = true;
localStorage.setItem('codeman-settings', JSON.stringify(settings));
if (window.app?.updateLocalEchoState) window.app.updateLocalEchoState();
});
await page.waitForTimeout(500);
// Force render and capture zoomed area of just the overlay
const result = await page.evaluate(() => {
const overlay = window.app?._localEchoOverlay;
if (!overlay || !overlay._overlay) return { error: 'no overlay' };
overlay._lastPromptPos = { row: 5, col: 0 };
overlay._pendingText = 'h';
overlay._lastRenderKey = '';
overlay._render();
const container = overlay._overlay;
const lineDiv = container.querySelector('div');
const charSpan = container.querySelector('div > span');
// Get precise rects
const lineDivRect = lineDiv?.getBoundingClientRect();
const charSpanRect = charSpan?.getBoundingClientRect();
const screenRect = document.querySelector('.xterm-screen')?.getBoundingClientRect();
// Check what's right below the line div
const belowY = (lineDivRect?.bottom || 0) + 1;
const belowX = lineDivRect?.left || 0;
const elBelow = document.elementFromPoint(belowX, belowY);
return {
dpr: window.devicePixelRatio,
lineDiv: {
rect: lineDivRect,
bg: lineDiv?.style.backgroundColor,
height: lineDiv?.style.height,
},
charSpan: {
rect: charSpanRect,
transform: charSpan?.style.transform,
// Check if span bottom exceeds lineDiv bottom
extendsBelow: charSpanRect && lineDivRect ?
(charSpanRect.bottom - lineDivRect.bottom).toFixed(2) : 'N/A',
},
elBelow: elBelow ? {
tag: elBelow.tagName,
class: elBelow.className,
} : null,
// Check the exact background color of the canvas
canvasBg: document.querySelector('.xterm-screen')?.style.backgroundColor,
themeBg: window.app?.terminal?.options?.theme?.background,
overlayBg: lineDiv?.style.backgroundColor,
};
});
console.log(JSON.stringify(result, null, 2));
// Take tight screenshot around the first char area
const screenEl = page.locator('.xterm-screen').first();
const screenBox = await screenEl.boundingBox();
if (screenBox) {
// Crop to just the overlay area (row 5, first few columns)
const cellH = 19;
const promptRow = 5;
const cropY = screenBox.y + (promptRow - 1) * cellH;
const cropH = cellH * 3;
await page.screenshot({
path: '/tmp/first-char-zoomed.png',
clip: { x: screenBox.x, y: cropY, width: 200, height: cropH },
});
console.log('Zoomed screenshot saved');
}
await page.evaluate(() => { window.app?._localEchoOverlay?.clear(); });
await browser.close();
console.log('Done');
-116
View File
@@ -1,116 +0,0 @@
import { chromium } from 'playwright';
const SESSION_ID = '9270f4e4-84f7-414c-b901-837f12c30e59';
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
page.on('console', msg => {
if (msg.type() === 'error') console.log('CONSOLE ERROR:', msg.text());
});
await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(4000);
// Click on test session tab
const tab = page.locator(`.session-tab[data-id="${SESSION_ID}"]`);
if (await tab.isVisible({ timeout: 3000 }).catch(() => false)) {
await tab.click();
await page.waitForTimeout(2000);
} else {
console.log('Tab not found, using whatever is active');
}
await page.waitForTimeout(2000);
// Ensure local echo is OFF
const localEchoToggle = page.locator('#localEchoToggle');
const isChecked = await localEchoToggle.isChecked().catch(() => false);
console.log('Local echo initially:', isChecked);
if (isChecked) {
await localEchoToggle.click();
await page.waitForTimeout(500);
}
// Type with echo OFF
await page.locator('.xterm-helper-textarea').first().focus();
await page.waitForTimeout(300);
await page.keyboard.type('hello test', { delay: 80 });
await page.waitForTimeout(1000);
const termScreen = page.locator('.xterm-screen').first();
await termScreen.screenshot({ path: '/tmp/echo-off-term.png' });
console.log('Saved /tmp/echo-off-term.png');
// Clear
for (let i = 0; i < 10; i++) await page.keyboard.press('Backspace');
await page.waitForTimeout(500);
// Enable local echo
await localEchoToggle.click();
await page.waitForTimeout(500);
console.log('Local echo now:', await localEchoToggle.isChecked().catch(() => false));
// Type with echo ON
await page.locator('.xterm-helper-textarea').first().focus();
await page.waitForTimeout(300);
await page.keyboard.type('hello test', { delay: 80 });
await page.waitForTimeout(1500);
await termScreen.screenshot({ path: '/tmp/echo-on-term.png' });
console.log('Saved /tmp/echo-on-term.png');
await page.screenshot({ path: '/tmp/echo-on-full.png' });
// Inspect overlay DOM
const overlayInfo = await page.evaluate(() => {
const overlay = document.querySelector('.zl-overlay');
if (!overlay) return { found: false };
const firstDiv = overlay.querySelector('div');
const spans = overlay.querySelectorAll('span');
const firstCharSpan = Array.from(spans).find(s => s.textContent && s.textContent.trim());
return {
found: true,
display: overlay.style.display,
top: overlay.style.top,
containerOverflow: overlay.style.overflow,
containerHeight: overlay.style.height,
firstDiv: firstDiv ? {
height: firstDiv.style.height,
top: firstDiv.style.top,
overflow: firstDiv.style.overflow,
backgroundColor: firstDiv.style.backgroundColor,
} : null,
firstCharSpan: firstCharSpan ? {
text: firstCharSpan.textContent,
transform: firstCharSpan.style.transform,
top: firstCharSpan.style.top,
height: firstCharSpan.style.height,
lineHeight: firstCharSpan.style.lineHeight,
width: firstCharSpan.style.width,
} : null,
spanCount: spans.length,
};
});
console.log('\nOverlay info:', JSON.stringify(overlayInfo, null, 2));
// Cell dimensions
const cellInfo = await page.evaluate(() => {
if (window.app?.sessions) {
for (const [, s] of Object.entries(window.app.sessions)) {
const dims = s.terminal?._core?._renderService?.dimensions;
if (dims) return { css: dims.css?.cell, device_char: dims.device?.char };
}
}
return { error: 'not found' };
});
console.log('Cell dims:', JSON.stringify(cellInfo, null, 2));
// Cleanup
await page.evaluate(async (id) => {
await fetch('/api/sessions/' + id, { method: 'DELETE' });
}, SESSION_ID);
await browser.close();
console.log('\nDone');
-114
View File
@@ -1,114 +0,0 @@
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
page.on('console', msg => console.log('CONSOLE:', msg.type(), msg.text()));
page.on('pageerror', err => console.log('PAGE ERROR:', err.message));
await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(4000);
// Create a fresh session
const resp = await page.evaluate(async () => {
const r = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'ov-test', mode: 'claude' })
});
return r.json();
});
const sid = resp.session?.id;
console.log('Session:', sid?.slice(0, 8));
await page.waitForTimeout(3000);
// Click on it
await page.locator(`.session-tab[data-id="${sid}"]`).click();
await page.waitForTimeout(3000);
// Check for the local echo toggle
const toggle = page.locator('#localEchoToggle');
const toggleVisible = await toggle.isVisible().catch(() => false);
console.log('Toggle visible:', toggleVisible);
if (!toggleVisible) {
// Maybe need to open settings or it's somewhere else
console.log('Looking for local echo toggle...');
const all = await page.locator('input[type="checkbox"]').all();
for (const cb of all) {
const id = await cb.getAttribute('id');
const label = await cb.evaluate(el => el.parentElement?.textContent?.trim().slice(0, 30));
console.log(' checkbox:', id, label);
}
}
// Get cell dimensions first
const cellInfo = await page.evaluate(() => {
if (window.app?.sessions) {
for (const [id, s] of Object.entries(window.app.sessions)) {
const dims = s.terminal?._core?._renderService?.dimensions;
if (dims) return {
id: id.slice(0, 8),
cssCell: dims.css?.cell,
deviceChar: dims.device?.char,
actualFull: dims,
};
}
}
return { error: 'no terminal' };
});
console.log('Cell info:', JSON.stringify(cellInfo, null, 2));
// Focus terminal and type with echo OFF
const textarea = page.locator('.xterm-helper-textarea').first();
await textarea.focus();
await page.waitForTimeout(500);
await page.keyboard.type('hello test', { delay: 80 });
await page.waitForTimeout(1500);
await page.screenshot({ path: '/tmp/echo-off-full.png' });
console.log('Saved echo-off-full.png');
// Clear
for (let i = 0; i < 10; i++) await page.keyboard.press('Backspace');
await page.waitForTimeout(500);
// Enable local echo
if (toggleVisible) {
await toggle.click();
await page.waitForTimeout(500);
console.log('Toggled local echo ON');
}
// Type again
await textarea.focus();
await page.waitForTimeout(300);
await page.keyboard.type('hello test', { delay: 80 });
await page.waitForTimeout(1500);
await page.screenshot({ path: '/tmp/echo-on-full.png' });
console.log('Saved echo-on-full.png');
// Get overlay info
const oi = await page.evaluate(() => {
const o = document.querySelector('.zl-overlay');
if (!o) return { found: false, html: 'none' };
return {
found: true,
display: o.style.display,
top: o.style.top,
childCount: o.children.length,
innerHTML: o.innerHTML.slice(0, 500),
};
});
console.log('Overlay:', JSON.stringify(oi, null, 2));
// Cleanup
if (sid) {
await page.evaluate(async (id) => {
await fetch('/api/sessions/' + id, { method: 'DELETE' });
}, sid);
}
await browser.close();
console.log('Done');
-109
View File
@@ -1,109 +0,0 @@
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(6000);
// Click first tab
await page.locator('.session-tab').first().click();
await page.waitForTimeout(4000);
// Get terminal and enable local echo via JS
const result = await page.evaluate(() => {
const term = window.app.terminal;
if (!term) return { error: 'no terminal' };
const dims = term._core._renderService.dimensions;
// Toggle local echo on via settings
const checkbox = document.getElementById('appSettingsLocalEcho');
return {
cellW: dims.css.cell.width,
cellH: dims.css.cell.height,
charTop: dims.device?.char?.top ?? 0,
charHeight: dims.device?.char?.height ?? 0,
dpr: window.devicePixelRatio,
fontSize: term.options.fontSize,
fontFamily: term.options.fontFamily,
localEchoCheckbox: !!checkbox,
localEchoChecked: checkbox?.checked,
};
});
console.log('Terminal info:', JSON.stringify(result, null, 2));
// Enable local echo via the app settings checkbox
await page.evaluate(() => {
const cb = document.getElementById('appSettingsLocalEcho');
if (cb && !cb.checked) {
cb.click();
cb.dispatchEvent(new Event('change', { bubbles: true }));
}
});
await page.waitForTimeout(500);
// Focus terminal and type
await page.locator('.xterm-helper-textarea').first().focus();
await page.waitForTimeout(300);
await page.keyboard.type('hello', { delay: 100 });
await page.waitForTimeout(1500);
// Take screenshots and analyze pixel positions
const pixelInfo = await page.evaluate(() => {
const overlay = document.querySelector('.zl-overlay');
if (!overlay) return { error: 'no overlay', overlayDisplay: overlay?.style?.display };
const spans = overlay.querySelectorAll('span');
const firstCharSpan = Array.from(spans).find(s => s.textContent === 'h');
if (!firstCharSpan) {
return { error: 'no h span', spanCount: spans.length, innerHTML: overlay.innerHTML.slice(0, 200) };
}
const spanRect = firstCharSpan.getBoundingClientRect();
const containerRect = overlay.getBoundingClientRect();
const lineDiv = firstCharSpan.parentElement;
const lineDivRect = lineDiv.getBoundingClientRect();
// Get the canvas position for comparison
const canvasEl = document.querySelector('.xterm-screen canvas');
const canvasRect = canvasEl?.getBoundingClientRect();
return {
span: {
top: spanRect.top,
bottom: spanRect.bottom,
height: spanRect.height,
relativeTop: spanRect.top - lineDivRect.top,
},
lineDiv: {
top: lineDivRect.top,
bottom: lineDivRect.bottom,
height: lineDivRect.height,
},
container: {
top: containerRect.top,
height: containerRect.height,
},
canvas: canvasRect ? {
top: canvasRect.top,
} : null,
spanStyle: {
transform: firstCharSpan.style.transform,
top: firstCharSpan.style.top,
height: firstCharSpan.style.height,
lineHeight: firstCharSpan.style.lineHeight,
},
};
});
console.log('Pixel info:', JSON.stringify(pixelInfo, null, 2));
// Screenshot the terminal area
const termScreen = page.locator('.xterm-screen').first();
await termScreen.screenshot({ path: '/tmp/overlay-test-zoomed.png' });
console.log('Screenshot saved to /tmp/overlay-test-zoomed.png');
await browser.close();
console.log('Done');
-69
View File
@@ -1,69 +0,0 @@
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(6000);
await page.locator('.session-tab').first().click();
await page.waitForTimeout(4000);
// Enable local echo
await page.evaluate(() => {
const s = JSON.parse(localStorage.getItem('codeman-settings') || '{}');
s.localEchoEnabled = true;
localStorage.setItem('codeman-settings', JSON.stringify(s));
window.app?.updateLocalEchoState?.();
});
await page.waitForTimeout(500);
// Force render overlay with single char at a realistic prompt position
await page.evaluate(() => {
const ov = window.app?._localEchoOverlay;
if (!ov) return;
// Find actual bottom row for a realistic position
const buf = ov._terminal?.buffer?.active;
const row = buf ? Math.max(0, buf.baseY + ov._terminal.rows - 2) - buf.viewportY : 5;
ov._lastPromptPos = { row, col: 0 };
ov._pendingText = 'hello test';
ov._lastRenderKey = '';
ov._render();
});
await page.waitForTimeout(500);
// Take full-page and zoomed screenshots
await page.screenshot({ path: '/tmp/sc-full.png' });
// Zoom into overlay area — find where the overlay actually is
const overlayRect = await page.evaluate(() => {
const ov = window.app?._localEchoOverlay?._overlay;
if (!ov) return null;
const r = ov.getBoundingClientRect();
const lineDiv = ov.querySelector('div');
const lr = lineDiv?.getBoundingClientRect();
return {
container: { x: r.x, y: r.y, w: r.width, h: r.height },
lineDiv: lr ? { x: lr.x, y: lr.y, w: lr.width, h: lr.height } : null,
};
});
console.log('Overlay rect:', JSON.stringify(overlayRect));
if (overlayRect?.lineDiv) {
const ld = overlayRect.lineDiv;
// Capture area around the overlay: 1 row above, the overlay, 1 row below
const margin = 20; // ~1 cell height
await page.screenshot({
path: '/tmp/sc-overlay-zoomed.png',
clip: {
x: Math.max(0, ld.x - 5),
y: Math.max(0, ld.y - margin),
width: Math.min(400, ld.w),
height: ld.h + margin * 2 + 5,
},
});
console.log('Zoomed screenshot saved');
}
await page.evaluate(() => { window.app?._localEchoOverlay?.clear(); });
await browser.close();
console.log('Done');
-69
View File
@@ -1,69 +0,0 @@
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(6000);
await page.locator('.session-tab').first().click();
await page.waitForTimeout(4000);
// Enable local echo and update state
await page.evaluate(() => {
const settings = JSON.parse(localStorage.getItem('codeman-settings') || '{}');
settings.localEchoEnabled = true;
localStorage.setItem('codeman-settings', JSON.stringify(settings));
if (window.app?.updateLocalEchoState) window.app.updateLocalEchoState();
});
await page.waitForTimeout(500);
// Force render with fake prompt position and inspect
const result = await page.evaluate(() => {
const overlay = window.app?._localEchoOverlay;
if (!overlay || !overlay._overlay) return { error: 'no overlay' };
const term = overlay._terminal;
const dims = term._core._renderService.dimensions;
const cellH = dims.css.cell.height;
const charTop = dims.device?.char?.top ?? 0;
const charHeight = dims.device?.char?.height ?? cellH;
// Calculate the correction
const cssCenter = (cellH - charHeight) / 2;
const correction = charTop - cssCenter;
// Force render
overlay._lastPromptPos = { row: 5, col: 0 };
overlay._pendingText = 'hello test';
overlay._lastRenderKey = '';
overlay._render();
const spans = overlay._overlay.querySelectorAll('span');
const firstChar = Array.from(spans).find(s => s.textContent === 'h');
return {
cellH, charTop, charHeight,
cssCenter,
correction: correction.toFixed(2),
firstChar: firstChar ? {
top: firstChar.style.top,
height: firstChar.style.height,
lineHeight: firstChar.style.lineHeight,
transform: firstChar.style.transform,
} : null,
};
});
console.log('Fix result:', JSON.stringify(result, null, 2));
// Take screenshot
await page.locator('.xterm-screen').first().screenshot({ path: '/tmp/fix-verify-term.png' });
console.log('Screenshot saved');
// Clean up
await page.evaluate(() => {
window.app?._localEchoOverlay?.clear();
});
await browser.close();
console.log('Done');
-61
View File
@@ -1,61 +0,0 @@
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
await page.goto('http://localhost:3099', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(6000);
await page.locator('.session-tab').first().click();
await page.waitForTimeout(4000);
await page.evaluate(() => {
const settings = JSON.parse(localStorage.getItem('codeman-settings') || '{}');
settings.localEchoEnabled = true;
localStorage.setItem('codeman-settings', JSON.stringify(settings));
if (window.app?.updateLocalEchoState) window.app.updateLocalEchoState();
});
const result = await page.evaluate(() => {
const overlay = window.app?._localEchoOverlay;
if (!overlay) return { error: 'no overlay' };
overlay._lastPromptPos = { row: 5, col: 0 };
overlay._pendingText = 'hello test';
overlay._lastRenderKey = '';
overlay._render();
const container = overlay._overlay;
const lineDiv = container.querySelector('div');
const charSpan = container.querySelector('div > span');
const lineDivRect = lineDiv?.getBoundingClientRect();
const charSpanRect = charSpan?.getBoundingClientRect();
return {
lineDiv: { bottom: lineDivRect?.bottom, height: lineDivRect?.height },
charSpan: {
bottom: charSpanRect?.bottom,
height: charSpanRect?.height,
transform: charSpan?.style.transform,
top: charSpan?.style.top,
lineHeight: charSpan?.style.lineHeight,
},
extendsBelow: charSpanRect && lineDivRect ?
(charSpanRect.bottom - lineDivRect.bottom).toFixed(4) : 'N/A',
};
});
console.log(JSON.stringify(result, null, 2));
// Screenshot
const screenEl = page.locator('.xterm-screen').first();
const screenBox = await screenEl.boundingBox();
if (screenBox) {
await page.screenshot({
path: '/tmp/no-overhang-zoomed.png',
clip: { x: screenBox.x, y: screenBox.y + 4 * 19, width: 200, height: 57 },
});
}
await page.evaluate(() => { window.app?._localEchoOverlay?.clear(); });
await browser.close();
console.log('Done');
-2
View File
@@ -1,7 +1,5 @@
/**
* Shared MockSession for tests that need terminal simulation.
*
* Copied from test/respawn-test-utils.ts (the canonical, most complete version).
* Used by respawn, route, and subagent tests.
*/
import { EventEmitter } from 'node:events';
+2 -3
View File
@@ -1,18 +1,17 @@
/**
* Reusable async test helpers.
* Extracted from respawn-test-utils.ts.
*/
/** Wait for an EventEmitter to emit a specific event, with timeout */
export function waitForEvent(
emitter: { once: (event: string, listener: (...args: unknown[]) => void) => void },
event: string,
timeoutMs = 5000,
timeoutMs = 5000
): Promise<unknown> {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`Timed out waiting for event "${event}" after ${timeoutMs}ms`)),
timeoutMs,
timeoutMs
);
emitter.once(event, (...args: unknown[]) => {
clearTimeout(timer);
-727
View File
@@ -1,727 +0,0 @@
/**
* @fileoverview Test Utilities for RespawnController Tests
*
* Provides respawn-specific mocks, helpers, and utilities for testing the
* RespawnController and its related AI checker components.
*
* General-purpose mocks (MockSession, terminalOutputs, etc.) have been moved
* to test/mocks/ — re-exported here for backward compatibility.
*
* @module test/respawn-test-utils
*/
import { EventEmitter } from 'node:events';
import { vi } from 'vitest';
import type { Session } from '../src/session.js';
import type { RespawnConfig, RespawnState } from '../src/respawn-controller.js';
import type {
AiCheckResult,
AiCheckState,
AiCheckStatus,
AiCheckVerdict,
AiIdleCheckConfig,
} from '../src/ai-idle-checker.js';
import type {
AiPlanCheckResult,
AiPlanCheckState,
AiPlanCheckStatus,
AiPlanCheckVerdict,
AiPlanCheckConfig,
} from '../src/ai-plan-checker.js';
// Re-export shared mocks for backward compatibility
export { MockSession, createMockSession, terminalOutputs } from './mocks/mock-session.js';
export { waitForEvent, createDeferred } from './mocks/test-helpers.js';
// ========== Time Manipulation Utilities ==========
/**
* Time controller for testing timeout-based behavior.
* Uses vitest's fake timers for deterministic time control.
*/
export interface TimeController {
/** Advance time by the specified milliseconds */
advanceBy(ms: number): Promise<void>;
/** Advance to the next timer */
advanceToNextTimer(): Promise<void>;
/** Run all pending timers */
runAllTimers(): Promise<void>;
/** Get the current fake time */
now(): number;
/** Reset to real timers */
useRealTimers(): void;
}
/**
* Create a time controller for testing timeout-based behavior.
* Call this in beforeEach and cleanup with controller.useRealTimers() in afterEach.
*/
export function createTimeController(): TimeController {
vi.useFakeTimers();
return {
async advanceBy(ms: number): Promise<void> {
await vi.advanceTimersByTimeAsync(ms);
},
async advanceToNextTimer(): Promise<void> {
await vi.runOnlyPendingTimersAsync();
},
async runAllTimers(): Promise<void> {
await vi.runAllTimersAsync();
},
now(): number {
return Date.now();
},
useRealTimers(): void {
vi.useRealTimers();
},
};
}
// ========== MockAiIdleChecker ==========
/**
* Configuration for MockAiIdleChecker behavior.
*/
export interface MockAiIdleCheckerOptions {
/** Default verdict to return */
defaultVerdict?: AiCheckVerdict;
/** Default reasoning to include */
defaultReasoning?: string;
/** Simulated check duration in ms */
checkDurationMs?: number;
/** Whether to start in disabled state */
startDisabled?: boolean;
/** Reason if starting disabled */
disabledReason?: string;
}
/**
* Mock AI idle checker for testing without spawning real Claude CLI.
* Allows configuring verdicts and simulating various states.
*/
export class MockAiIdleChecker extends EventEmitter {
private config: AiIdleCheckConfig;
private _status: AiCheckStatus = 'ready';
private _lastVerdict: AiCheckVerdict | null = null;
private _lastReasoning: string | null = null;
private _lastCheckDurationMs: number | null = null;
private _cooldownEndsAt: number | null = null;
private _consecutiveErrors: number = 0;
private _totalChecks: number = 0;
private _disabledReason: string | null = null;
// Queued results for sequential calls
private queuedResults: AiCheckResult[] = [];
private defaultOptions: MockAiIdleCheckerOptions;
private cooldownTimer: NodeJS.Timeout | null = null;
constructor(
public sessionId: string,
config: Partial<AiIdleCheckConfig> = {},
options: MockAiIdleCheckerOptions = {},
) {
super();
this.config = {
enabled: true,
model: 'claude-opus-4-5-20251101',
maxContextChars: 16000,
checkTimeoutMs: 90000,
cooldownMs: 180000,
errorCooldownMs: 60000,
maxConsecutiveErrors: 3,
...config,
};
this.defaultOptions = {
defaultVerdict: 'IDLE',
defaultReasoning: 'Mock verdict',
checkDurationMs: 100,
startDisabled: false,
...options,
};
if (this.defaultOptions.startDisabled) {
this._status = 'disabled';
this._disabledReason = this.defaultOptions.disabledReason || 'Disabled for testing';
}
}
get status(): AiCheckStatus {
return this._status;
}
getState(): AiCheckState {
return {
status: this._status,
lastVerdict: this._lastVerdict,
lastReasoning: this._lastReasoning,
lastCheckDurationMs: this._lastCheckDurationMs,
cooldownEndsAt: this._cooldownEndsAt,
consecutiveErrors: this._consecutiveErrors,
totalChecks: this._totalChecks,
disabledReason: this._disabledReason,
};
}
isOnCooldown(): boolean {
return this._cooldownEndsAt !== null && Date.now() < this._cooldownEndsAt;
}
getCooldownRemainingMs(): number {
if (this._cooldownEndsAt === null) return 0;
return Math.max(0, this._cooldownEndsAt - Date.now());
}
queueResult(result: AiCheckResult): void {
this.queuedResults.push(result);
}
queueResults(...results: AiCheckResult[]): void {
this.queuedResults.push(...results);
}
setNextIdle(reasoning: string = 'Mock: IDLE'): void {
this.queueResult({
verdict: 'IDLE',
reasoning,
durationMs: this.defaultOptions.checkDurationMs || 100,
});
}
setNextWorking(reasoning: string = 'Mock: WORKING'): void {
this.queueResult({
verdict: 'WORKING',
reasoning,
durationMs: this.defaultOptions.checkDurationMs || 100,
});
}
setNextError(reasoning: string = 'Mock: ERROR'): void {
this.queueResult({
verdict: 'ERROR',
reasoning,
durationMs: this.defaultOptions.checkDurationMs || 100,
});
}
async check(_terminalBuffer: string): Promise<AiCheckResult> {
if (this._status === 'disabled') {
return { verdict: 'ERROR', reasoning: `Disabled: ${this._disabledReason}`, durationMs: 0 };
}
if (this.isOnCooldown()) {
return { verdict: 'ERROR', reasoning: 'On cooldown', durationMs: 0 };
}
if (this._status === 'checking') {
return { verdict: 'ERROR', reasoning: 'Already checking', durationMs: 0 };
}
this._status = 'checking';
this._totalChecks++;
this.emit('checkStarted');
// Simulate async check
await new Promise((resolve) => setTimeout(resolve, 10));
// Get result from queue or use default
const result = this.queuedResults.shift() || {
verdict: this.defaultOptions.defaultVerdict!,
reasoning: this.defaultOptions.defaultReasoning!,
durationMs: this.defaultOptions.checkDurationMs!,
};
this._lastVerdict = result.verdict;
this._lastReasoning = result.reasoning;
this._lastCheckDurationMs = result.durationMs;
if (result.verdict === 'IDLE') {
this._consecutiveErrors = 0;
this._status = 'ready';
} else if (result.verdict === 'WORKING') {
this._consecutiveErrors = 0;
this.startCooldown(this.config.cooldownMs);
} else {
this._consecutiveErrors++;
if (this._consecutiveErrors >= this.config.maxConsecutiveErrors) {
this._status = 'disabled';
this._disabledReason = `${this.config.maxConsecutiveErrors} consecutive errors`;
this.emit('disabled', this._disabledReason);
} else {
this.startCooldown(this.config.errorCooldownMs);
}
}
this.emit('checkCompleted', result);
return result;
}
cancel(): void {
if (this._status === 'checking') {
this._status = 'ready';
this.emit('log', '[MockAiIdleChecker] Check cancelled');
}
}
reset(): void {
this.cancel();
this.clearCooldown();
this._lastVerdict = null;
this._lastReasoning = null;
this._lastCheckDurationMs = null;
this._consecutiveErrors = 0;
this.queuedResults = [];
this._status = this._disabledReason ? 'disabled' : 'ready';
}
updateConfig(config: Partial<AiIdleCheckConfig>): void {
const filteredConfig = Object.fromEntries(
Object.entries(config).filter(([, v]) => v !== undefined),
) as Partial<AiIdleCheckConfig>;
this.config = { ...this.config, ...filteredConfig };
if (config.enabled === false) {
this._disabledReason = 'Disabled by config';
this._status = 'disabled';
} else if (config.enabled === true && this._status === 'disabled') {
this._disabledReason = null;
this._status = 'ready';
}
}
getConfig(): AiIdleCheckConfig {
return { ...this.config };
}
// ========== Test Helpers ==========
forceCooldown(durationMs: number): void {
this.startCooldown(durationMs);
}
forceDisabled(reason: string = 'Forced disabled for testing'): void {
this._status = 'disabled';
this._disabledReason = reason;
this.emit('disabled', reason);
}
forceReady(): void {
this.clearCooldown();
this._status = 'ready';
this._disabledReason = null;
}
private startCooldown(durationMs: number): void {
this.clearCooldown();
this._cooldownEndsAt = Date.now() + durationMs;
this._status = 'cooldown';
this.emit('cooldownStarted', this._cooldownEndsAt);
this.cooldownTimer = setTimeout(() => {
this._cooldownEndsAt = null;
this._status = 'ready';
this.emit('cooldownEnded');
}, durationMs);
}
private clearCooldown(): void {
if (this.cooldownTimer) {
clearTimeout(this.cooldownTimer);
this.cooldownTimer = null;
}
this._cooldownEndsAt = null;
if (this._status === 'cooldown') {
this._status = 'ready';
}
}
}
// ========== MockAiPlanChecker ==========
/**
* Configuration for MockAiPlanChecker behavior.
*/
export interface MockAiPlanCheckerOptions {
/** Default verdict to return */
defaultVerdict?: AiPlanCheckVerdict;
/** Default reasoning to include */
defaultReasoning?: string;
/** Simulated check duration in ms */
checkDurationMs?: number;
/** Whether to start in disabled state */
startDisabled?: boolean;
/** Reason if starting disabled */
disabledReason?: string;
}
/**
* Mock AI plan checker for testing without spawning real Claude CLI.
* Allows configuring verdicts and simulating various states.
*/
export class MockAiPlanChecker extends EventEmitter {
private config: AiPlanCheckConfig;
private _status: AiPlanCheckStatus = 'ready';
private _lastVerdict: AiPlanCheckVerdict | null = null;
private _lastReasoning: string | null = null;
private _lastCheckDurationMs: number | null = null;
private _cooldownEndsAt: number | null = null;
private _consecutiveErrors: number = 0;
private _totalChecks: number = 0;
private _disabledReason: string | null = null;
// Queued results for sequential calls
private queuedResults: AiPlanCheckResult[] = [];
private defaultOptions: MockAiPlanCheckerOptions;
private cooldownTimer: NodeJS.Timeout | null = null;
constructor(
public sessionId: string,
config: Partial<AiPlanCheckConfig> = {},
options: MockAiPlanCheckerOptions = {},
) {
super();
this.config = {
enabled: true,
model: 'claude-opus-4-5-20251101',
maxContextChars: 8000,
checkTimeoutMs: 60000,
cooldownMs: 30000,
errorCooldownMs: 30000,
maxConsecutiveErrors: 3,
...config,
};
this.defaultOptions = {
defaultVerdict: 'PLAN_MODE',
defaultReasoning: 'Mock verdict',
checkDurationMs: 100,
startDisabled: false,
...options,
};
if (this.defaultOptions.startDisabled) {
this._status = 'disabled';
this._disabledReason = this.defaultOptions.disabledReason || 'Disabled for testing';
}
}
get status(): AiPlanCheckStatus {
return this._status;
}
getState(): AiPlanCheckState {
return {
status: this._status,
lastVerdict: this._lastVerdict,
lastReasoning: this._lastReasoning,
lastCheckDurationMs: this._lastCheckDurationMs,
cooldownEndsAt: this._cooldownEndsAt,
consecutiveErrors: this._consecutiveErrors,
totalChecks: this._totalChecks,
disabledReason: this._disabledReason,
};
}
isOnCooldown(): boolean {
return this._cooldownEndsAt !== null && Date.now() < this._cooldownEndsAt;
}
getCooldownRemainingMs(): number {
if (this._cooldownEndsAt === null) return 0;
return Math.max(0, this._cooldownEndsAt - Date.now());
}
queueResult(result: AiPlanCheckResult): void {
this.queuedResults.push(result);
}
queueResults(...results: AiPlanCheckResult[]): void {
this.queuedResults.push(...results);
}
setNextPlanMode(reasoning: string = 'Mock: PLAN_MODE'): void {
this.queueResult({
verdict: 'PLAN_MODE',
reasoning,
durationMs: this.defaultOptions.checkDurationMs || 100,
});
}
setNextNotPlanMode(reasoning: string = 'Mock: NOT_PLAN_MODE'): void {
this.queueResult({
verdict: 'NOT_PLAN_MODE',
reasoning,
durationMs: this.defaultOptions.checkDurationMs || 100,
});
}
setNextError(reasoning: string = 'Mock: ERROR'): void {
this.queueResult({
verdict: 'ERROR',
reasoning,
durationMs: this.defaultOptions.checkDurationMs || 100,
});
}
async check(_terminalBuffer: string): Promise<AiPlanCheckResult> {
if (this._status === 'disabled') {
return { verdict: 'ERROR', reasoning: `Disabled: ${this._disabledReason}`, durationMs: 0 };
}
if (this.isOnCooldown()) {
return { verdict: 'ERROR', reasoning: 'On cooldown', durationMs: 0 };
}
if (this._status === 'checking') {
return { verdict: 'ERROR', reasoning: 'Already checking', durationMs: 0 };
}
this._status = 'checking';
this._totalChecks++;
this.emit('checkStarted');
// Simulate async check
await new Promise((resolve) => setTimeout(resolve, 10));
// Get result from queue or use default
const result = this.queuedResults.shift() || {
verdict: this.defaultOptions.defaultVerdict!,
reasoning: this.defaultOptions.defaultReasoning!,
durationMs: this.defaultOptions.checkDurationMs!,
};
this._lastVerdict = result.verdict;
this._lastReasoning = result.reasoning;
this._lastCheckDurationMs = result.durationMs;
if (result.verdict === 'PLAN_MODE') {
this._consecutiveErrors = 0;
this._status = 'ready';
} else if (result.verdict === 'NOT_PLAN_MODE') {
this._consecutiveErrors = 0;
this.startCooldown(this.config.cooldownMs);
} else {
this._consecutiveErrors++;
if (this._consecutiveErrors >= this.config.maxConsecutiveErrors) {
this._status = 'disabled';
this._disabledReason = `${this.config.maxConsecutiveErrors} consecutive errors`;
this.emit('disabled', this._disabledReason);
} else {
this.startCooldown(this.config.errorCooldownMs);
}
}
this.emit('checkCompleted', result);
return result;
}
cancel(): void {
if (this._status === 'checking') {
this._status = 'ready';
this.emit('log', '[MockAiPlanChecker] Check cancelled');
}
}
reset(): void {
this.cancel();
this.clearCooldown();
this._lastVerdict = null;
this._lastReasoning = null;
this._lastCheckDurationMs = null;
this._consecutiveErrors = 0;
this.queuedResults = [];
this._status = this._disabledReason ? 'disabled' : 'ready';
}
updateConfig(config: Partial<AiPlanCheckConfig>): void {
const filteredConfig = Object.fromEntries(
Object.entries(config).filter(([, v]) => v !== undefined),
) as Partial<AiPlanCheckConfig>;
this.config = { ...this.config, ...filteredConfig };
if (config.enabled === false) {
this._disabledReason = 'Disabled by config';
this._status = 'disabled';
} else if (config.enabled === true && this._status === 'disabled') {
this._disabledReason = null;
this._status = 'ready';
}
}
getConfig(): AiPlanCheckConfig {
return { ...this.config };
}
// ========== Test Helpers ==========
forceCooldown(durationMs: number): void {
this.startCooldown(durationMs);
}
forceDisabled(reason: string = 'Forced disabled for testing'): void {
this._status = 'disabled';
this._disabledReason = reason;
this.emit('disabled', reason);
}
forceReady(): void {
this.clearCooldown();
this._status = 'ready';
this._disabledReason = null;
}
private startCooldown(durationMs: number): void {
this.clearCooldown();
this._cooldownEndsAt = Date.now() + durationMs;
this._status = 'cooldown';
this.emit('cooldownStarted', this._cooldownEndsAt);
this.cooldownTimer = setTimeout(() => {
this._cooldownEndsAt = null;
this._status = 'ready';
this.emit('cooldownEnded');
}, durationMs);
}
private clearCooldown(): void {
if (this.cooldownTimer) {
clearTimeout(this.cooldownTimer);
this.cooldownTimer = null;
}
this._cooldownEndsAt = null;
if (this._status === 'cooldown') {
this._status = 'ready';
}
}
}
// ========== State Transition Helpers ==========
/**
* State transition event for tracking.
*/
export interface StateTransition {
from: RespawnState;
to: RespawnState;
timestamp: number;
}
/**
* Create a state tracker that records all state transitions.
* Attach to a RespawnController via controller.on('stateChanged', tracker.record).
*/
export function createStateTracker() {
const transitions: StateTransition[] = [];
return {
record(to: RespawnState, from: RespawnState): void {
transitions.push({ from, to, timestamp: Date.now() });
},
getTransitions(): StateTransition[] {
return [...transitions];
},
getStates(): RespawnState[] {
return transitions.map((t) => t.to);
},
hasVisited(state: RespawnState): boolean {
return transitions.some((t) => t.to === state);
},
hasTransition(from: RespawnState, to: RespawnState): boolean {
return transitions.some((t) => t.from === from && t.to === to);
},
getCurrentState(): RespawnState | undefined {
return transitions.length > 0 ? transitions[transitions.length - 1].to : undefined;
},
clear(): void {
transitions.length = 0;
},
};
}
/**
* Create an event recorder for tracking all events from a RespawnController.
*/
export function createEventRecorder() {
const events: Array<{ type: string; args: unknown[]; timestamp: number }> = [];
return {
handler(type: string): (...args: unknown[]) => void {
return (...args: unknown[]) => {
events.push({ type, args, timestamp: Date.now() });
};
},
getEvents(): Array<{ type: string; args: unknown[]; timestamp: number }> {
return [...events];
},
getEventsOfType(type: string): Array<{ type: string; args: unknown[]; timestamp: number }> {
return events.filter((e) => e.type === type);
},
hasEvent(type: string): boolean {
return events.some((e) => e.type === type);
},
countEvents(type: string): number {
return events.filter((e) => e.type === type).length;
},
clear(): void {
events.length = 0;
},
};
}
// ========== Factory Functions ==========
/**
* Default fast test configuration.
* Uses short timeouts for faster test execution.
*/
export const FAST_TEST_CONFIG: Partial<RespawnConfig> = {
idleTimeoutMs: 50,
interStepDelayMs: 20,
completionConfirmMs: 50,
noOutputTimeoutMs: 300,
autoAcceptDelayMs: 100,
aiIdleCheckEnabled: false,
aiPlanCheckEnabled: false,
};
/**
* Configuration with AI checks enabled but short timeouts.
*/
export const AI_ENABLED_TEST_CONFIG: Partial<RespawnConfig> = {
...FAST_TEST_CONFIG,
aiIdleCheckEnabled: true,
aiIdleCheckTimeoutMs: 500,
aiIdleCheckCooldownMs: 200,
aiPlanCheckEnabled: true,
aiPlanCheckTimeoutMs: 500,
aiPlanCheckCooldownMs: 200,
};
// ========== Test Assertion Helpers ==========
/**
* Wait for a specific state to be reached.
* Useful for async state transition testing.
*/
export async function waitForState(
controller: {
state: RespawnState;
on: (event: string, handler: (state: RespawnState) => void) => void;
},
targetState: RespawnState,
timeoutMs: number = 1000,
): Promise<void> {
if (controller.state === targetState) return;
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(`Timeout waiting for state ${targetState}, current: ${controller.state}`));
}, timeoutMs);
const handler = (state: RespawnState) => {
if (state === targetState) {
clearTimeout(timeout);
resolve();
}
};
controller.on('stateChanged', handler);
});
}