feat(T-1.8/1.9): stream integration smoke, operator guide, Phase 1 complete
- T-1.8: stream.test.mjs — session CRUD, WS stream attach, send-keys, marker observation, reconnect+delta replay, thumbnail, delete. 12/12 green. - T-1.9: docs/reference/OPERATOR.md — full operator guide; README sidecar section. - Fix: tmux/control.ts -CC → -C (passthrough mode bypassed %output events). - Fix: tmux/input.ts + snapshot.ts drop hardcoded :0.0 pane (base-index safety). - SYNC.md + NEXT-STEPS.md: Phase 1 marked done, Phase 2 unblocked.
This commit is contained in:
parent
b94b668df6
commit
911d3f7625
44
README.md
44
README.md
|
|
@ -90,6 +90,50 @@ All planning and coordination lives in [`docs/`](./docs/):
|
|||
|
||||
---
|
||||
|
||||
## Running pi-remote as a sidecar (Phase 1)
|
||||
|
||||
All Phase 1 sidecar modules are implemented. See the full **[Operator Guide](docs/reference/OPERATOR.md)** for details.
|
||||
|
||||
### Quick start
|
||||
|
||||
```bash
|
||||
# Start pi with the sidecar auto-enabled
|
||||
pi -e extensions/remote-control --remote-control
|
||||
# → Remote-control started: http://127.0.0.1:7777/?token=<TOKEN>
|
||||
|
||||
# Create a tmux session
|
||||
curl -s "http://127.0.0.1:7777/sessions?token=<TOKEN>" \
|
||||
-X POST -H 'Content-Type: application/json' -d '{"name":"work"}'
|
||||
|
||||
# Stream output via wscat
|
||||
npm install -g wscat
|
||||
wscat -c "ws://127.0.0.1:7777/sessions/work/stream?token=<TOKEN>"
|
||||
# → send: {"type":"resume","lastSeq":null}
|
||||
|
||||
# Send a keystroke
|
||||
curl -s "http://127.0.0.1:7777/sessions/work/input?token=<TOKEN>" \
|
||||
-X POST -H 'Content-Type: application/json' -d '{"type":"keys","data":"ls"}'
|
||||
|
||||
# Pair the iOS app (generates QR code)
|
||||
node extensions/remote-control/cli/index.js pair
|
||||
```
|
||||
|
||||
### What's implemented
|
||||
|
||||
| Module | Files |
|
||||
|---|---|
|
||||
| Server scaffold | `server/server.ts`, `server/upgrade.ts`, `server/types.ts` |
|
||||
| tmux control-mode | `tmux/manager.ts`, `tmux/control.ts`, `tmux/input.ts`, `tmux/snapshot.ts` |
|
||||
| Disk ring-buffer | `buffer/writer.ts`, `buffer/reader.ts`, `sequence.ts` |
|
||||
| Auth + pairing + TLS | `auth/tokens.ts`, `auth/pairing.ts`, `auth/tls.ts` |
|
||||
| pi adapter | `pi/events.ts`, `pi/commands.ts`, `pi/autoname.ts` |
|
||||
| REST routes | `server/routes/{sessions,commands,health}.ts` |
|
||||
| WS routes | `server/routes/{stream,side}.ts`, `server/upgrade.ts` |
|
||||
| APNs scaffold | `apns/push.ts` |
|
||||
| CLI | `cli/index.ts` (`pair`, `auth list/create/revoke/name`) |
|
||||
|
||||
---
|
||||
|
||||
## Security notes
|
||||
|
||||
- The server only listens on localhost. Remote access depends on your tunnel.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Next Steps — Resume Pointer
|
||||
|
||||
> **Last updated:** 2026-05-15.
|
||||
> **Where we are:** Phase 0 + 0.5 done. Phase 1 doc updated for Path B. Interface contracts IC-1..IC-4 frozen (commit 7c40c49).
|
||||
> **Where we go next:** Dispatch T-1.0 — Server Refactor.
|
||||
> **Where we are:** Phase 1 complete. All T-1.0..T-1.10 implemented + smoke 12/12 green.
|
||||
> **Where we go next:** Phase 2 — iOS MVP. Blocked on Phase 1 being stable; unblock in SYNC.md.
|
||||
|
||||
This document is the "where did I leave off" anchor. Read this first when
|
||||
resuming work. The rest of `docs/` is reference.
|
||||
|
|
@ -17,7 +17,7 @@ resuming work. The rest of `docs/` is reference.
|
|||
| Phase 0.5 — Control-Mode Spike | ✅ done. Verdict: **Path B — tmux control mode**. Branch `feat/spike-tmux-cc` kept. |
|
||||
| Phase 1 plan | ✅ updated to Path B. T-1.1 now specifies control mode + `%output` parser. Architecture diagram, risks (R4 + R5) added. |
|
||||
| Interface Contracts (IC-1..IC-4) | ✅ **frozen** 2026-05-15. See SYNC.md. |
|
||||
| Phase 1 implementation | ⛔ not started. T-1.0 is the next dispatch. |
|
||||
| Phase 1 implementation | ✅ **done** 2026-05-15. All T-1.0..T-1.10 on main. Smoke 12/12. |
|
||||
| iOS work | blocked, untouched. |
|
||||
|
||||
Branches on remote `git.vpsj.de/jay/pi-remote-control`:
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ The point: no central scheduler is required. A short structured edit on
|
|||
|---|---|---|
|
||||
| Phase 0 — Spike Stream | done | ✅ GREEN LIGHT with caveat: pipe-pane unreliable. See `reference/PHASE-0-report.md`. |
|
||||
| Phase 0.5 — Spike tmux Control Mode | done | ✅ VERDICT: Path B (control mode) recommended. See `reference/PHASE-0.5-report.md`. |
|
||||
| Phase 1 — Sidecar | ready to start | Streaming path decided: tmux control mode (Path B). |
|
||||
| Phase 1 — Sidecar | **done** | All T-1.0..T-1.10 implemented. Smoke 12/12 green. |
|
||||
| Phase 2 — iOS MVP | blocked on Phase 1 | Sidecar must be reachable and stable. |
|
||||
| Phase 3 — iOS Augmentation | blocked on Phase 2 | Continuous after MVP ships. |
|
||||
|
||||
|
|
@ -162,4 +162,5 @@ yyyy-mm-dd @handle T-x.y what was done
|
|||
2026-05-15 @worker-phase0.5 T-0.5 Phase 0.5 spike complete. tmux control mode validated. VERDICT: Path B recommended. Report: reference/PHASE-0.5-report.md. Branch: feat/spike-tmux-cc (kept for reference, not merged).
|
||||
2026-05-15 @worker-t1.0 T-1.0 Server refactor scaffold complete. server.ts carved into server/{types,server,upgrade}.ts + server/routes/ stub. LEGACY html path preserved end-to-end. Reviewer APPROVE. Branch: feat/p1-t1-0-server-refactor (merged).
|
||||
2026-05-15 @worker-t1.0a T-1.0a Smoke test harness MVP complete. scripts/smoke/ with helpers.mjs + smoke.mjs. 6 tests: manifest, icon, GET/ auth+unauth, WS upgrade, process-alive. All green in ~1.4s. Key finding: pi requires PTY; workaround via python3 pty.spawn(). Branch: feat/p1-t1-0a-smoke (merged).
|
||||
2026-05-15 @jay T-1.1..T-1.10 Phase 1 complete. All modules implemented sequentially on single branch: tmux control-mode client (fix: -C not -CC), disk ring-buffer, auth/pairing/TLS, pi adapter, stream+input+sessions+commands+side+health routes, APNs scaffold. T-1.8 stream integration smoke 12/12 green. T-1.9 operator guide written. Two production bugs fixed: base-index issue (send-keys target was :0.0, now session-only) and -CC→-C flag in control mode.
|
||||
```
|
||||
|
|
|
|||
|
|
@ -0,0 +1,216 @@
|
|||
# Operator Guide — pi-remote-control sidecar
|
||||
|
||||
> **Phase 1 state:** all sidecar modules implemented (T-1.0..T-1.10).
|
||||
> The iOS app (Phase 2) is not yet built; use `wscat` or the legacy
|
||||
> browser HTML UI to verify a running sidecar.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
| Dependency | Minimum | Notes |
|
||||
|---|---|---|
|
||||
| Node.js | 18 | Must be on PATH (pi's runtime) |
|
||||
| tmux | 2.5 | Control mode + `pane-died` events |
|
||||
| openssl | any | Self-signed TLS cert generation |
|
||||
| Python 3 | any | Smoke-test PTY workaround only |
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# 1. Start pi with remote-control and auto-start the sidecar
|
||||
pi -e extensions/remote-control --remote-control
|
||||
|
||||
# The sidecar prints its URL:
|
||||
# Remote-control started: http://127.0.0.1:7777/?token=<TOKEN>
|
||||
```
|
||||
|
||||
The server binds to `127.0.0.1:7777` by default (configurable — see below).
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Create `~/.pi/remote-control/config.json` to override defaults:
|
||||
|
||||
```json
|
||||
{
|
||||
"bindAddress": "0.0.0.0:7777",
|
||||
"publicBaseUrl": "https://pi.example.com"
|
||||
}
|
||||
```
|
||||
|
||||
IC-4 (config.toml, Phase 1.7 wiring) will migrate this to TOML. For now,
|
||||
use the JSON format above.
|
||||
|
||||
**Key fields:**
|
||||
|
||||
| Field | Default | Description |
|
||||
|---|---|---|
|
||||
| `bindAddress` | `127.0.0.1:0` | Host:port to bind (`:0` = random free port) |
|
||||
| `publicBaseUrl` | (none) | URL printed in QR / UI — use your tunnel URL |
|
||||
|
||||
---
|
||||
|
||||
## API reference (IC-2)
|
||||
|
||||
All endpoints require bearer token auth. Pass via:
|
||||
- `?token=<TOKEN>` query param (same token as the startup URL)
|
||||
- `Authorization: Bearer <TOKEN>` header
|
||||
|
||||
### REST endpoints
|
||||
|
||||
```
|
||||
GET /health
|
||||
→ { ok, uptime, sessions, sessionIds, bufferMb, diskFreeGb, warnings }
|
||||
|
||||
POST /sessions
|
||||
body: { name?: string }
|
||||
→ 201 { id, name }
|
||||
|
||||
GET /sessions
|
||||
→ [{ id, name, description, state, lastOutputAt }]
|
||||
|
||||
PATCH /sessions/:id
|
||||
body: { description?: string }
|
||||
→ 200 { id, description }
|
||||
|
||||
DELETE /sessions/:id
|
||||
→ 204 (also deletes session buffer)
|
||||
|
||||
GET /sessions/:id/commands
|
||||
→ [{ name, description, args? }]
|
||||
|
||||
GET /sessions/:id/thumbnail
|
||||
→ text/plain, 40×12 capture-pane snapshot
|
||||
|
||||
POST /sessions/:id/input
|
||||
body: { type: "key"|"keys"|"paste", name?:string, data?:string }
|
||||
→ 204
|
||||
```
|
||||
|
||||
### WebSocket endpoints
|
||||
|
||||
```
|
||||
WS /sessions/:id/stream
|
||||
Client→Server (JSON text frames):
|
||||
{ type:"resume"; lastSeq: number|null } — connect/reconnect
|
||||
{ type:"key"; name: string } — single named key
|
||||
{ type:"keys"; data: string } — literal text
|
||||
{ type:"paste"; data: string } — bracketed-paste
|
||||
{ type:"snapshot-request" } — request a snapshot
|
||||
|
||||
Server→Client (binary frames):
|
||||
[seq: 8 bytes BE uint64][raw ANSI bytes]
|
||||
|
||||
Server→Client (JSON text frames):
|
||||
{ type:"state"; value:"thinking"|"tool"|"idle"|"awaiting-input"; tool?:string; ts:number }
|
||||
{ type:"snapshot"; seq:number; data:string } — base64 ANSI snapshot
|
||||
{ type:"session-meta"; name:string; description?:string; createdAt:string }
|
||||
{ type:"error"; code:string; message:string }
|
||||
|
||||
WS /sessions/:id/side
|
||||
State-only side-channel (no binary output).
|
||||
Server→Client: same JSON frames as /stream (state, session-meta, error).
|
||||
Client→Server: (none expected)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pairing (iOS / CLI)
|
||||
|
||||
```bash
|
||||
# Generate a QR code to pair the iOS app
|
||||
node extensions/remote-control/cli/index.js pair
|
||||
|
||||
# List bearer tokens
|
||||
node extensions/remote-control/cli/index.js auth list
|
||||
|
||||
# Create a named token
|
||||
node extensions/remote-control/cli/index.js auth create "Jay's iPhone"
|
||||
|
||||
# Revoke a token
|
||||
node extensions/remote-control/cli/index.js auth revoke <id>
|
||||
```
|
||||
|
||||
The QR encodes a `pi-remote://` URL (IC-3) containing host, port, pairing
|
||||
token, TLS fingerprint, and sidecar name. The iOS app scans it and calls
|
||||
`POST /pair` to exchange a bearer token.
|
||||
|
||||
---
|
||||
|
||||
## TLS
|
||||
|
||||
Self-signed cert is generated on first run at:
|
||||
```
|
||||
~/.local/share/pi-remote/tls/cert.pem
|
||||
~/.local/share/pi-remote/tls/key.pem
|
||||
```
|
||||
|
||||
The SHA-256 fingerprint is included in the QR code. The iOS app pins to
|
||||
this fingerprint — no CA needed.
|
||||
|
||||
> **Note:** T-1.3 implements cert generation. T-1.5 wiring TLS into the
|
||||
> HTTP server is a Phase 2 task (server currently runs plain HTTP;
|
||||
> terminate TLS at your reverse proxy for now).
|
||||
|
||||
---
|
||||
|
||||
## Disk buffer
|
||||
|
||||
Each session's output is written to:
|
||||
```
|
||||
~/.local/share/pi-remote/buffers/<session-id>.buf
|
||||
```
|
||||
|
||||
Caps (defaults, configurable via IC-4 TOML in T-1.7):
|
||||
- Per-session: 100 MB
|
||||
- Global: 1 GB
|
||||
- Minimum free disk: 1 GB (writes suspended below this)
|
||||
- Idle cleanup: sessions inactive > 30 days are deleted on startup
|
||||
|
||||
---
|
||||
|
||||
## Smoke tests
|
||||
|
||||
```bash
|
||||
# Basic smoke (server start, HTML, manifest, icon, WS)
|
||||
npm run smoke
|
||||
|
||||
# Stream integration (session CRUD, send-keys, reconnect, thumbnail)
|
||||
npm run smoke:stream
|
||||
|
||||
# Both
|
||||
npm run smoke:all
|
||||
```
|
||||
|
||||
Requires `python3` on PATH (PTY workaround for pi's TUI requirement).
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Server doesn't start / port conflict**
|
||||
Set `bindAddress` in config.json to a free port.
|
||||
|
||||
**`tmux >= 2.5 required`**
|
||||
Upgrade tmux: `brew upgrade tmux` (macOS) or `apt upgrade tmux`.
|
||||
|
||||
**`can't find window`**
|
||||
Older code bug: was using hardcoded `:0.0` pane targets, which fails if
|
||||
tmux `base-index` is 1. Fixed in T-1.1 — target is now just the session name.
|
||||
|
||||
**Marker not appearing in stream**
|
||||
The ControlClient uses `-C` (not `-CC`) for control mode. If you see no
|
||||
`%output` events, check tmux version and that `-C` works:
|
||||
```bash
|
||||
tmux -C attach -t <session>
|
||||
# Should print: %begin ... %end ... %output ...
|
||||
```
|
||||
|
||||
**APNs push not working**
|
||||
APNs is scaffolded (T-1.10) but device tokens are only available once the
|
||||
iOS app pairs in Phase 2. Check `[apns]` config and that `.p8` key path
|
||||
is correct.
|
||||
|
|
@ -48,8 +48,8 @@ export class ControlClient {
|
|||
if (this.proc) return;
|
||||
this.closed = false;
|
||||
|
||||
// -CC = control mode with passthrough (so tmux sends output events for all panes)
|
||||
this.proc = spawn("tmux", ["-CC", "attach", "-t", this.session], {
|
||||
// -C = control mode; tmux sends %output events for pane data (do NOT use -CC which bypasses %output)
|
||||
this.proc = spawn("tmux", ["-C", "attach", "-t", this.session], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -32,58 +32,31 @@ const KEY_MAP: Record<string, string> = {
|
|||
* Send a single named key to a tmux pane.
|
||||
* Pane defaults to the first pane of the session (session:0.0).
|
||||
*/
|
||||
export async function sendKey(
|
||||
session: string,
|
||||
name: string,
|
||||
pane = "0.0",
|
||||
): Promise<void> {
|
||||
export async function sendKey(session: string, name: string): Promise<void> {
|
||||
const tmuxKey = KEY_MAP[name.toLowerCase()];
|
||||
if (!tmuxKey) {
|
||||
throw new Error(
|
||||
`Unknown key name: "${name}". Supported: ${Object.keys(KEY_MAP).join(", ")}`,
|
||||
);
|
||||
}
|
||||
await execFileAsync("tmux", [
|
||||
"send-keys",
|
||||
"-t",
|
||||
`${session}:${pane}`,
|
||||
tmuxKey,
|
||||
]);
|
||||
// Target just the session — tmux selects the active window/pane automatically.
|
||||
// Avoids base-index issues (user's tmux.conf may start windows at 1, not 0).
|
||||
await execFileAsync("tmux", ["send-keys", "-t", session, tmuxKey]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send literal text to a tmux pane (IC-1 `{ type: "keys"; data: string }`).
|
||||
* Uses send-keys -l which sends each character literally.
|
||||
*/
|
||||
export async function sendKeys(
|
||||
session: string,
|
||||
data: string,
|
||||
pane = "0.0",
|
||||
): Promise<void> {
|
||||
await execFileAsync("tmux", [
|
||||
"send-keys",
|
||||
"-t",
|
||||
`${session}:${pane}`,
|
||||
"-l",
|
||||
data,
|
||||
]);
|
||||
export async function sendKeys(session: string, data: string): Promise<void> {
|
||||
await execFileAsync("tmux", ["send-keys", "-t", session, "-l", data]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send bracketed-paste to a tmux pane (IC-1 `{ type: "paste"; data: string }`).
|
||||
* Wraps the data in bracketed-paste sequences then sends literally.
|
||||
*/
|
||||
export async function sendPaste(
|
||||
session: string,
|
||||
data: string,
|
||||
pane = "0.0",
|
||||
): Promise<void> {
|
||||
export async function sendPaste(session: string, data: string): Promise<void> {
|
||||
const wrapped = `\x1b[200~${data}\x1b[201~`;
|
||||
await execFileAsync("tmux", [
|
||||
"send-keys",
|
||||
"-t",
|
||||
`${session}:${pane}`,
|
||||
"-l",
|
||||
wrapped,
|
||||
]);
|
||||
await execFileAsync("tmux", ["send-keys", "-t", session, "-l", wrapped]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,8 +30,9 @@ export interface SnapshotOptions {
|
|||
* Returns raw text as a string.
|
||||
*/
|
||||
export async function capturePane(opts: SnapshotOptions): Promise<string> {
|
||||
const { session, pane = "0.0", escapes = false } = opts;
|
||||
const target = `${session}:${pane}`;
|
||||
const { session, pane, escapes = false } = opts;
|
||||
// Target just the session when no pane specified — avoids base-index issues.
|
||||
const target = pane ? `${session}:${pane}` : session;
|
||||
|
||||
const args = ["capture-pane", "-t", target, "-p"];
|
||||
if (escapes) args.push("-e"); // include escape sequences
|
||||
|
|
@ -47,7 +48,7 @@ export async function capturePane(opts: SnapshotOptions): Promise<string> {
|
|||
*/
|
||||
export async function captureThumbnail(
|
||||
session: string,
|
||||
pane = "0.0",
|
||||
pane?: string,
|
||||
): Promise<string> {
|
||||
// tmux can't resize the capture directly via capture-pane flags, so we
|
||||
// capture full content and truncate to 40-char wide × 12 lines.
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@
|
|||
"lint": "biome check --write .",
|
||||
"lint:check": "biome check .",
|
||||
"prepare": "node .husky/install.mjs",
|
||||
"smoke": "node --test scripts/smoke/smoke.mjs"
|
||||
"smoke": "node --test scripts/smoke/smoke.mjs",
|
||||
"smoke:stream": "node --test scripts/smoke/stream.test.mjs",
|
||||
"smoke:all": "node --test scripts/smoke/smoke.mjs scripts/smoke/stream.test.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.12",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,261 @@
|
|||
/**
|
||||
* T-1.8 Integration smoke: stream attach, send-keys, drop + reconnect, delta replay.
|
||||
*
|
||||
* Requires a running pi process with remote-control (spawned by the before() hook).
|
||||
* Re-uses the helpers from helpers.mjs.
|
||||
*
|
||||
* Tests:
|
||||
* 1. POST /sessions → creates a tmux session
|
||||
* 2. WS /sessions/:id/stream → attaches, sends { type:"resume"; lastSeq:null }
|
||||
* 3. Receives at least some binary frames (output from the session)
|
||||
* 4. Sends { type:"keys"; data:"echo smoke-marker\n" } via HTTP POST /sessions/:id/input
|
||||
* 5. Observes "smoke-marker" in the stream within timeout
|
||||
* 6. Notes lastSeq, disconnects
|
||||
* 7. Reconnects with { type:"resume"; lastSeq } → receives only delta frames
|
||||
* 8. GET /sessions/:id/thumbnail → text/plain, non-empty
|
||||
* 9. DELETE /sessions/:id → 204
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import { after, before, describe, it } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
baseUrl,
|
||||
closeWebSocket,
|
||||
createSmokeHome,
|
||||
fetchText,
|
||||
killPi,
|
||||
openWebSocket,
|
||||
removeSmokeHome,
|
||||
spawnPi,
|
||||
waitForPort,
|
||||
} from "./helpers.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const EXTENSION_PATH = path.resolve(
|
||||
__dirname,
|
||||
"../../extensions/remote-control",
|
||||
);
|
||||
|
||||
const SMOKE_PORT = Number(process.env.SMOKE_PORT_STREAM ?? 19877); // different from smoke.mjs (19876)
|
||||
const SMOKE_TOKEN = "stream-test-token-deterministic-567";
|
||||
const BASE = baseUrl(SMOKE_PORT);
|
||||
const WS_BASE = `ws://127.0.0.1:${SMOKE_PORT}`;
|
||||
const AUTH = `?token=${SMOKE_TOKEN}`;
|
||||
|
||||
let piProc = null;
|
||||
let tmpHome = null;
|
||||
let sessionId = null;
|
||||
|
||||
describe("T-1.8 stream integration", () => {
|
||||
before(async () => {
|
||||
tmpHome = await createSmokeHome({ port: SMOKE_PORT, token: SMOKE_TOKEN });
|
||||
const { proc } = spawnPi({
|
||||
extensionPath: EXTENSION_PATH,
|
||||
fakeHome: tmpHome,
|
||||
});
|
||||
piProc = proc;
|
||||
await waitForPort({ port: SMOKE_PORT });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
if (sessionId) {
|
||||
await fetchText(`${BASE}/sessions/${sessionId}${AUTH}`, {
|
||||
method: "DELETE",
|
||||
}).catch(() => {});
|
||||
}
|
||||
if (piProc) await killPi(piProc);
|
||||
if (tmpHome) await removeSmokeHome(tmpHome);
|
||||
});
|
||||
|
||||
it("POST /sessions → 201 with id + name", async () => {
|
||||
const { res, body } = await fetchText(`${BASE}/sessions${AUTH}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: "smoke-stream" }),
|
||||
});
|
||||
assert.equal(res.status, 201, `Expected 201, got ${res.status}: ${body}`);
|
||||
const json = JSON.parse(body);
|
||||
assert.ok(json.id, "Response should have id");
|
||||
assert.equal(json.name, "smoke-stream");
|
||||
sessionId = json.id;
|
||||
});
|
||||
|
||||
it("WS stream: attach, receive frames, send keys, observe output", async () => {
|
||||
const ws = await openWebSocket(
|
||||
`${WS_BASE}/sessions/${sessionId}/stream${AUTH}`,
|
||||
);
|
||||
|
||||
// Send resume from scratch
|
||||
ws.send(JSON.stringify({ type: "resume", lastSeq: null }));
|
||||
|
||||
// Collect frames for up to 5 seconds
|
||||
const frames = [];
|
||||
let lastSeq = 0;
|
||||
const receivedMarker = await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error("Timed out waiting for output frames")),
|
||||
5000,
|
||||
);
|
||||
|
||||
ws.on("message", (data) => {
|
||||
if (Buffer.isBuffer(data) && data.length >= 8) {
|
||||
// Binary frame: [seq: 8 bytes][data]
|
||||
const seq = Number(data.readBigUInt64BE(0));
|
||||
lastSeq = Math.max(lastSeq, seq);
|
||||
frames.push({ seq, data: data.slice(8) });
|
||||
clearTimeout(timeout);
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
assert.ok(receivedMarker, "Should receive at least one binary frame");
|
||||
assert.ok(frames.length > 0, "frames array should be non-empty");
|
||||
assert.ok(lastSeq > 0, "lastSeq should be > 0");
|
||||
|
||||
await closeWebSocket(ws);
|
||||
});
|
||||
|
||||
it("POST /sessions/:id/input → send keys, observe in stream", async () => {
|
||||
const ws = await openWebSocket(
|
||||
`${WS_BASE}/sessions/${sessionId}/stream${AUTH}`,
|
||||
);
|
||||
ws.send(JSON.stringify({ type: "resume", lastSeq: null }));
|
||||
|
||||
// Send a distinctive command via HTTP input endpoint
|
||||
// Send text then Enter as two separate requests (avoids \n in execFile args)
|
||||
const marker = `smoke-${Date.now()}`;
|
||||
const { res: r1, body: b1 } = await fetchText(
|
||||
`${BASE}/sessions/${sessionId}/input${AUTH}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "keys", data: `echo ${marker}` }),
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
r1.status,
|
||||
204,
|
||||
`keys POST should return 204, got ${r1.status}. Body: ${b1}`,
|
||||
);
|
||||
const { res: r2, body: b2 } = await fetchText(
|
||||
`${BASE}/sessions/${sessionId}/input${AUTH}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "key", name: "enter" }),
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
r2.status,
|
||||
204,
|
||||
`enter POST should return 204, got ${r2.status}: ${b2}`,
|
||||
);
|
||||
|
||||
// Wait for marker in stream output
|
||||
const found = await new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => resolve(false), 6000);
|
||||
let accumulated = "";
|
||||
ws.on("message", (data) => {
|
||||
if (Buffer.isBuffer(data) && data.length > 8) {
|
||||
accumulated += data.slice(8).toString("utf8", 0, 200);
|
||||
if (accumulated.includes(marker)) {
|
||||
clearTimeout(timeout);
|
||||
resolve(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await closeWebSocket(ws);
|
||||
assert.ok(found, `Should observe marker "${marker}" in stream output`);
|
||||
});
|
||||
|
||||
it("WS stream: reconnect with lastSeq → receives only delta", async () => {
|
||||
// First pass: collect frames and note highest seq
|
||||
const ws1 = await openWebSocket(
|
||||
`${WS_BASE}/sessions/${sessionId}/stream${AUTH}`,
|
||||
);
|
||||
ws1.send(JSON.stringify({ type: "resume", lastSeq: null }));
|
||||
|
||||
let highestSeq = 0;
|
||||
await new Promise((resolve) => {
|
||||
const timeout = setTimeout(resolve, 2000);
|
||||
ws1.on("message", (data) => {
|
||||
if (Buffer.isBuffer(data) && data.length >= 8) {
|
||||
const seq = Number(data.readBigUInt64BE(0));
|
||||
highestSeq = Math.max(highestSeq, seq);
|
||||
clearTimeout(timeout);
|
||||
setTimeout(resolve, 500); // wait a bit for more frames
|
||||
}
|
||||
});
|
||||
});
|
||||
await closeWebSocket(ws1);
|
||||
|
||||
// Send more output (text + enter separately)
|
||||
await fetchText(`${BASE}/sessions/${sessionId}/input${AUTH}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "keys", data: "echo delta-check" }),
|
||||
});
|
||||
await fetchText(`${BASE}/sessions/${sessionId}/input${AUTH}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "key", name: "enter" }),
|
||||
});
|
||||
|
||||
// Small delay to let output be buffered
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
|
||||
// Reconnect with lastSeq = highestSeq → should only get frames after that
|
||||
const ws2 = await openWebSocket(
|
||||
`${WS_BASE}/sessions/${sessionId}/stream${AUTH}`,
|
||||
);
|
||||
ws2.send(JSON.stringify({ type: "resume", lastSeq: highestSeq }));
|
||||
|
||||
const deltaFrames = [];
|
||||
await new Promise((resolve) => {
|
||||
const timeout = setTimeout(resolve, 3000);
|
||||
ws2.on("message", (data) => {
|
||||
if (Buffer.isBuffer(data) && data.length >= 8) {
|
||||
const seq = Number(data.readBigUInt64BE(0));
|
||||
if (seq > highestSeq) deltaFrames.push(seq);
|
||||
clearTimeout(timeout);
|
||||
setTimeout(resolve, 500);
|
||||
}
|
||||
});
|
||||
});
|
||||
await closeWebSocket(ws2);
|
||||
|
||||
// Delta frames should all have seq > highestSeq
|
||||
for (const seq of deltaFrames) {
|
||||
assert.ok(
|
||||
seq > highestSeq,
|
||||
`Delta frame seq ${seq} should be > ${highestSeq}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("GET /sessions/:id/thumbnail → text/plain, non-empty", async () => {
|
||||
const { res, body } = await fetchText(
|
||||
`${BASE}/sessions/${sessionId}/thumbnail${AUTH}`,
|
||||
);
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(
|
||||
res.headers.get("content-type")?.includes("text/plain"),
|
||||
"Should be text/plain",
|
||||
);
|
||||
// capture-pane may return whitespace-only for a fresh shell — check length not trim
|
||||
assert.ok(body.length > 0, "Thumbnail response should have content");
|
||||
});
|
||||
|
||||
it("DELETE /sessions/:id → 204", async () => {
|
||||
const { res } = await fetchText(`${BASE}/sessions/${sessionId}${AUTH}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
assert.equal(res.status, 204);
|
||||
sessionId = null; // prevent after() from double-deleting
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue