Four small, high-delight features that turn passive capture into glanceable signals.
- Sync chip on project cards: ahead/behind vs upstream + last-commit time, folded
into the existing concurrent per-repo metadata pass (git rev-list --count
--left-right @{u}...HEAD + git log -1 --format=%ct; no upstream → undefined, no route).
- Cost budget guard: COST_BUDGET_USD env (0 = off); a per-session one-shot latch
(Session.budgetNotified, cost is monotonic so never re-armed) fires a single push
on threshold crossing in manager.handleStatusLine; the already-broadcast telemetry
frame carries the warn (tg-cost-warn styling derived from costUsd>=budget via
/config/ui — no new ServerMessage). web-push title added to sw-push.js.
- "While you were away" digest: GET /digest?since= → {finished, needsInput, stuck,
totalCostUsd, sessions[]} aggregate over manager.list(); FE banner on reconnect.
- Recent commits per project: src/http/git-log.ts (NUL-delimited git log → CommitInfo[]),
GET /projects/log?path= (isValidGitDir), textContent-inert render in project detail.
All git via execFile (no shell) + validated cwd; new routes read-only; commit
messages rendered via textContent. Verified: typecheck + build:web clean, 1904 pass
at --test-timeout=30000 (two default-5s failures are slow-sandbox real-subprocess
timeout flakes — the known ring-buffer test + a new real-git-clone sync test — not
logic regressions).
547 lines
19 KiB
TypeScript
547 lines
19 KiB
TypeScript
/**
|
|
* T12 tests — session/session.ts (PTY lifecycle + multi-client sharing).
|
|
*
|
|
* ARCHITECTURE §3.4 / §4.4 + the cross-validated fixes:
|
|
* - M4: spawn failure THROWS, never swallowed.
|
|
* - M5: every ws.send is guarded by `ws.readyState === WS_OPEN`.
|
|
* - L1: detach-then-exit keeps the session (exit not delivered when no client attached).
|
|
* - L4: writeInput/setClientDims are no-ops once the PTY has exited.
|
|
* - v0.4: a session may hold MANY clients (multi-device mirror). Output/exit are
|
|
* broadcast to all; the PTY size is the MIN cols/rows across clients.
|
|
*
|
|
* node-pty is mocked (`vi.mock`) so `spawn` returns a MockIPty — the sandbox blocks
|
|
* real posix_spawn, and the mock lets us drive onData/onExit deterministically.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
|
|
import type { Config, Dims, Session, ServerMessage, WebSocketLike } from '../src/types.js';
|
|
import { WS_OPEN } from '../src/types.js';
|
|
import { createMockPty, type MockIPty } from './helpers/mock-pty.js';
|
|
|
|
// ── node-pty mock ───────────────────────────────────────────────────────────
|
|
let nextPty: MockIPty;
|
|
let spawnError: Error | null = null;
|
|
const spawnCalls: Array<{ file: string; args: string[] | string; options: unknown }> = [];
|
|
|
|
vi.mock('node-pty', () => ({
|
|
spawn: (file: string, args: string[] | string, options: unknown) => {
|
|
spawnCalls.push({ file, args, options });
|
|
if (spawnError) throw spawnError;
|
|
return nextPty;
|
|
},
|
|
}));
|
|
|
|
// Imported AFTER vi.mock so the module under test binds to the mocked spawn.
|
|
const { createSession, attachWs, detachWs, writeInput, setClientDims, kill } =
|
|
await import('../src/session/session.js');
|
|
|
|
// ── helpers ─────────────────────────────────────────────────────────────────
|
|
const CFG: Config = {
|
|
port: 3000,
|
|
bindHost: '0.0.0.0',
|
|
shellPath: '/bin/zsh',
|
|
homeDir: '/home/tester',
|
|
idleTtlMs: 86_400_000,
|
|
scrollbackBytes: 2 * 1024 * 1024,
|
|
maxPayloadBytes: 1024 * 1024,
|
|
wsPath: '/term',
|
|
maxSessions: 50,
|
|
maxMsgsPerSec: 2000,
|
|
permTimeoutMs: 300_000,
|
|
reapIntervalMs: 60_000,
|
|
previewBytes: 24 * 1024,
|
|
useTmux: false,
|
|
allowedOrigins: [],
|
|
// v0.6 project manager fields
|
|
projectRoots: ['/home/tester'],
|
|
projectScanDepth: 4,
|
|
projectScanTtlMs: 10_000,
|
|
projectDirtyCheck: true,
|
|
editorCmd: 'code',
|
|
};
|
|
|
|
const DIMS: Dims = { cols: 80, rows: 24 };
|
|
|
|
interface MockWs extends WebSocketLike {
|
|
readyState: number;
|
|
sent: string[];
|
|
closed: boolean;
|
|
}
|
|
|
|
function createMockWs(readyState: number = WS_OPEN): MockWs {
|
|
const sent: string[] = [];
|
|
return {
|
|
readyState,
|
|
sent,
|
|
closed: false,
|
|
send(data: string) {
|
|
sent.push(data);
|
|
},
|
|
close() {
|
|
this.closed = true;
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Decode the JSON frames a ws received back into ServerMessages. */
|
|
function received(ws: MockWs): ServerMessage[] {
|
|
return ws.sent.map((s) => JSON.parse(s) as ServerMessage);
|
|
}
|
|
|
|
function newSession(onExit: (s: Session) => void = () => {}): Session {
|
|
nextPty = createMockPty();
|
|
return createSession(CFG, DIMS, 1_000, onExit);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
spawnError = null;
|
|
spawnCalls.length = 0;
|
|
});
|
|
|
|
// ── createSession / spawn ─────────────────────────────────────────────────────
|
|
describe('createSession', () => {
|
|
it('spawns the PTY via node-pty with shellPath, cwd and dims', () => {
|
|
const s = newSession();
|
|
|
|
expect(spawnCalls).toHaveLength(1);
|
|
expect(spawnCalls[0]!.file).toBe(CFG.shellPath);
|
|
const opts = spawnCalls[0]!.options as { cwd?: string; cols?: number; rows?: number };
|
|
expect(opts.cwd).toBe(CFG.homeDir);
|
|
expect(opts.cols).toBe(DIMS.cols);
|
|
expect(opts.rows).toBe(DIMS.rows);
|
|
|
|
expect(s.pty).toBe(nextPty);
|
|
expect(s.meta.shellPath).toBe(CFG.shellPath);
|
|
expect(s.meta.createdAt).toBe(1_000);
|
|
expect(s.clients.size).toBe(0);
|
|
expect(s.exitedAt).toBeNull();
|
|
expect(s.exitCode).toBeNull();
|
|
});
|
|
|
|
it('initialises the W3(b) cost-budget latch to false', () => {
|
|
const s = newSession();
|
|
expect(s.budgetNotified).toBe(false);
|
|
});
|
|
|
|
it('THROWS (does not swallow) when spawn fails (M4)', () => {
|
|
spawnError = new Error('spawn failed: /no/such/shell ENOENT');
|
|
nextPty = createMockPty();
|
|
|
|
expect(() => createSession(CFG, DIMS, 1_000, () => {})).toThrow(/spawn failed/);
|
|
});
|
|
});
|
|
|
|
// ── onData → buffer + broadcast ────────────────────────────────────────────────
|
|
describe('onData', () => {
|
|
it('appends output to the ring buffer and refreshes lastOutputAt', () => {
|
|
const s = newSession();
|
|
const pty = s.pty as MockIPty;
|
|
|
|
pty.emitData('hello ');
|
|
pty.emitData('world');
|
|
|
|
expect(s.buffer.snapshot()).toBe('\x1b[0mhello world');
|
|
expect(s.lastOutputAt).toBeGreaterThanOrEqual(1_000);
|
|
});
|
|
|
|
it('broadcasts output to the attached client as an `output` message', () => {
|
|
const s = newSession();
|
|
const ws = createMockWs();
|
|
attachWs(s, ws);
|
|
ws.sent.length = 0; // drop the replay frame from attach
|
|
|
|
(s.pty as MockIPty).emitData('abc');
|
|
|
|
expect(received(ws)).toEqual([{ type: 'output', data: 'abc' }]);
|
|
});
|
|
|
|
it('broadcasts output to EVERY client sharing the session (multi-device)', () => {
|
|
const s = newSession();
|
|
const a = createMockWs();
|
|
const b = createMockWs();
|
|
attachWs(s, a);
|
|
attachWs(s, b);
|
|
a.sent.length = 0;
|
|
b.sent.length = 0;
|
|
|
|
(s.pty as MockIPty).emitData('shared');
|
|
|
|
expect(received(a)).toEqual([{ type: 'output', data: 'shared' }]);
|
|
expect(received(b)).toEqual([{ type: 'output', data: 'shared' }]);
|
|
});
|
|
|
|
it('still buffers output when no client is attached (does not throw)', () => {
|
|
const s = newSession();
|
|
expect(() => (s.pty as MockIPty).emitData('orphan')).not.toThrow();
|
|
expect(s.buffer.snapshot()).toContain('orphan');
|
|
});
|
|
|
|
it('does NOT send to a client whose readyState is not OPEN (M5)', () => {
|
|
const s = newSession();
|
|
const ws = createMockWs(WS_OPEN);
|
|
attachWs(s, ws);
|
|
ws.sent.length = 0;
|
|
|
|
ws.readyState = 3; // CLOSED
|
|
(s.pty as MockIPty).emitData('lost');
|
|
|
|
expect(ws.sent).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// ── attachWs (join, no kick) ──────────────────────────────────────────────────
|
|
describe('attachWs', () => {
|
|
it('replays the buffer snapshot to the newly attached client', () => {
|
|
const s = newSession();
|
|
(s.pty as MockIPty).emitData('prior output');
|
|
|
|
const ws = createMockWs();
|
|
attachWs(s, ws);
|
|
|
|
expect(s.clients.has(ws)).toBe(true);
|
|
expect(received(ws)).toEqual([{ type: 'output', data: '\x1b[0mprior output' }]);
|
|
});
|
|
|
|
it('a second attach JOINS (does not kick) — both clients stay connected', () => {
|
|
const s = newSession();
|
|
const a = createMockWs();
|
|
attachWs(s, a);
|
|
|
|
const b = createMockWs();
|
|
attachWs(s, b);
|
|
|
|
// Both present; the first was NOT closed.
|
|
expect(s.clients.has(a)).toBe(true);
|
|
expect(s.clients.has(b)).toBe(true);
|
|
expect(a.closed).toBe(false);
|
|
|
|
// Only the joining client got the replay; live output reaches both.
|
|
a.sent.length = 0;
|
|
b.sent.length = 0;
|
|
(s.pty as MockIPty).emitData('live');
|
|
expect(received(a)).toEqual([{ type: 'output', data: 'live' }]);
|
|
expect(received(b)).toEqual([{ type: 'output', data: 'live' }]);
|
|
});
|
|
|
|
it('attaching alone does NOT change the PTY size (the active device keeps it)', () => {
|
|
const s = newSession(); // spawn dims 80x24
|
|
const wide = createMockWs();
|
|
setClientDims(s, wide, 200, 50);
|
|
expect(s.pty.cols).toBe(200);
|
|
|
|
const hidden = createMockWs();
|
|
attachWs(s, hidden); // joins but never reports dims (background mirror)
|
|
// The join must not change the PTY — the active viewer keeps 200x50.
|
|
expect(s.pty.cols).toBe(200);
|
|
expect(s.pty.rows).toBe(50);
|
|
});
|
|
|
|
it('latest-writer-wins: the most recent client dims drive the PTY size', () => {
|
|
const s = newSession();
|
|
const desktop = createMockWs();
|
|
const ipad = createMockWs();
|
|
|
|
setClientDims(s, desktop, 200, 50);
|
|
expect(s.pty.cols).toBe(200);
|
|
expect(s.pty.rows).toBe(50);
|
|
|
|
// The device used next (smaller iPad) takes over — it is now full-screen.
|
|
setClientDims(s, ipad, 100, 40);
|
|
expect(s.pty.cols).toBe(100);
|
|
expect(s.pty.rows).toBe(40);
|
|
|
|
// Switching back to the desktop (re-fit on focus) reclaims its size.
|
|
setClientDims(s, desktop, 200, 50);
|
|
expect(s.pty.cols).toBe(200);
|
|
expect(s.pty.rows).toBe(50);
|
|
});
|
|
|
|
});
|
|
|
|
// ── detachWs (remove one client) ──────────────────────────────────────────────
|
|
describe('detachWs', () => {
|
|
it('removes the client and stamps detachedAt only when the LAST one leaves', () => {
|
|
const s = newSession();
|
|
const a = createMockWs();
|
|
const b = createMockWs();
|
|
attachWs(s, a);
|
|
attachWs(s, b);
|
|
|
|
detachWs(s, a, 5_000);
|
|
// one client remains → still "attached", no detach stamp
|
|
expect(s.clients.has(a)).toBe(false);
|
|
expect(s.clients.has(b)).toBe(true);
|
|
expect(s.detachedAt).toBeNull();
|
|
|
|
detachWs(s, b, 6_000);
|
|
// last client gone → detached
|
|
expect(s.clients.size).toBe(0);
|
|
expect(s.detachedAt).toBe(6_000);
|
|
expect((s.pty as MockIPty).killed).toBe(false);
|
|
});
|
|
|
|
it('does NOT resize when a client leaves (the remaining device keeps its size)', () => {
|
|
const s = newSession();
|
|
const wide = createMockWs();
|
|
const narrow = createMockWs();
|
|
attachWs(s, wide);
|
|
setClientDims(s, wide, 200, 50);
|
|
attachWs(s, narrow);
|
|
setClientDims(s, narrow, 90, 30); // narrow is the latest writer → 90x30
|
|
|
|
detachWs(s, narrow, 5_000);
|
|
// detach does not resize; PTY stays at the last set size until a client re-fits.
|
|
expect(s.pty.cols).toBe(90);
|
|
expect(s.pty.rows).toBe(30);
|
|
});
|
|
|
|
it('keeps the PTY alive after the last detach: further onData still fills the buffer', () => {
|
|
const s = newSession();
|
|
const ws = createMockWs();
|
|
attachWs(s, ws);
|
|
detachWs(s, ws, 5_000);
|
|
|
|
(s.pty as MockIPty).emitData('background work');
|
|
|
|
expect((s.pty as MockIPty).killed).toBe(false);
|
|
expect(s.buffer.snapshot()).toContain('background work');
|
|
});
|
|
});
|
|
|
|
// ── onExit ────────────────────────────────────────────────────────────────────
|
|
describe('onExit', () => {
|
|
it('sets exitedAt/exitCode, broadcasts `exit` to all clients, and calls injected onExit', () => {
|
|
const onExit = vi.fn();
|
|
nextPty = createMockPty();
|
|
const s = createSession(CFG, DIMS, 1_000, onExit);
|
|
|
|
const a = createMockWs();
|
|
const b = createMockWs();
|
|
attachWs(s, a);
|
|
attachWs(s, b);
|
|
a.sent.length = 0;
|
|
b.sent.length = 0;
|
|
|
|
(s.pty as MockIPty).emitExit(0);
|
|
|
|
expect(s.exitedAt).not.toBeNull();
|
|
expect(s.exitCode).toBe(0);
|
|
expect(received(a)).toEqual([{ type: 'exit', code: 0 }]);
|
|
expect(received(b)).toEqual([{ type: 'exit', code: 0 }]);
|
|
expect(onExit).toHaveBeenCalledWith(s);
|
|
});
|
|
|
|
it('after the last detach, exit is NOT delivered but the session is preserved (L1)', () => {
|
|
const onExit = vi.fn();
|
|
nextPty = createMockPty();
|
|
const s = createSession(CFG, DIMS, 1_000, onExit);
|
|
|
|
const ws = createMockWs();
|
|
attachWs(s, ws);
|
|
detachWs(s, ws, 5_000);
|
|
ws.sent.length = 0;
|
|
|
|
(s.pty as MockIPty).emitExit(137);
|
|
|
|
expect(s.exitedAt).not.toBeNull();
|
|
expect(s.exitCode).toBe(137);
|
|
expect(ws.sent).toHaveLength(0);
|
|
expect(onExit).toHaveBeenCalledWith(s);
|
|
});
|
|
|
|
it('does not send exit to a client whose readyState is not OPEN (M5)', () => {
|
|
nextPty = createMockPty();
|
|
const s = createSession(CFG, DIMS, 1_000, () => {});
|
|
const ws = createMockWs(WS_OPEN);
|
|
attachWs(s, ws);
|
|
ws.sent.length = 0;
|
|
ws.readyState = 3; // CLOSED
|
|
|
|
(s.pty as MockIPty).emitExit(1);
|
|
|
|
expect(ws.sent).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// ── writeInput / setClientDims after exit (L4) + dims idempotence ──────────────
|
|
describe('writeInput / setClientDims', () => {
|
|
it('writeInput forwards to pty.write while alive', () => {
|
|
const s = newSession();
|
|
writeInput(s, 'ls\r');
|
|
expect((s.pty as MockIPty).writes).toEqual(['ls\r']);
|
|
});
|
|
|
|
it('setClientDims forwards to pty.resize while alive', () => {
|
|
const s = newSession();
|
|
const ws = createMockWs();
|
|
attachWs(s, ws);
|
|
setClientDims(s, ws, 100, 40);
|
|
expect((s.pty as MockIPty).resizes).toContainEqual({ cols: 100, rows: 40 });
|
|
});
|
|
|
|
it('setClientDims is idempotent: same min cols/rows are skipped', () => {
|
|
const s = newSession();
|
|
const ws = createMockWs();
|
|
attachWs(s, ws); // 80x24, equals spawn dims → no resize yet
|
|
expect((s.pty as MockIPty).resizes).toHaveLength(0);
|
|
|
|
setClientDims(s, ws, 90, 24); // changed → applied
|
|
setClientDims(s, ws, 90, 24); // unchanged again → skipped
|
|
expect((s.pty as MockIPty).resizes).toEqual([{ cols: 90, rows: 24 }]);
|
|
});
|
|
|
|
it('ignores writeInput once the PTY has exited (L4)', () => {
|
|
const s = newSession();
|
|
(s.pty as MockIPty).emitExit(0);
|
|
|
|
writeInput(s, 'should be dropped');
|
|
expect((s.pty as MockIPty).writes).toHaveLength(0);
|
|
});
|
|
|
|
it('ignores setClientDims once the PTY has exited (L4)', () => {
|
|
const s = newSession();
|
|
const ws = createMockWs();
|
|
attachWs(s, ws);
|
|
(s.pty as MockIPty).emitExit(0);
|
|
|
|
setClientDims(s, ws, 200, 50);
|
|
expect((s.pty as MockIPty).resizes).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// ── kill ──────────────────────────────────────────────────────────────────────
|
|
describe('kill', () => {
|
|
it('kills the underlying PTY', () => {
|
|
const s = newSession();
|
|
kill(s);
|
|
expect((s.pty as MockIPty).killed).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ── spawn env (T-spawn-env / B2) ──────────────────────────────────────────────
|
|
describe('spawn env', () => {
|
|
it('injects WEBTERM_STATUSLINE_URL pointing to /hook/status (B2)', () => {
|
|
newSession();
|
|
const opts = spawnCalls[0]!.options as { env?: Record<string, string> };
|
|
expect(opts.env?.['WEBTERM_STATUSLINE_URL']).toBe(
|
|
`http://127.0.0.1:${CFG.port}/hook/status`,
|
|
);
|
|
});
|
|
|
|
it('still injects WEBTERM_SESSION and WEBTERM_HOOK_URL (existing env vars)', () => {
|
|
const s = newSession();
|
|
const opts = spawnCalls[0]!.options as { env?: Record<string, string> };
|
|
expect(opts.env?.['WEBTERM_SESSION']).toBe(s.meta.id);
|
|
expect(opts.env?.['WEBTERM_HOOK_URL']).toBe(`http://127.0.0.1:${CFG.port}/hook`);
|
|
});
|
|
|
|
it('uses port from cfg (not hardcoded) in all URL env vars', () => {
|
|
nextPty = createMockPty();
|
|
const altCfg: Config = { ...CFG, port: 4321 };
|
|
createSession(altCfg, DIMS, 1_000, () => {});
|
|
const opts = spawnCalls[0]!.options as { env?: Record<string, string> };
|
|
expect(opts.env?.['WEBTERM_STATUSLINE_URL']).toContain('4321');
|
|
expect(opts.env?.['WEBTERM_HOOK_URL']).toContain('4321');
|
|
});
|
|
});
|
|
|
|
// ── session initialization — v0.7 fields (T-spawn-env) ───────────────────────
|
|
describe('session v0.7 field initialization', () => {
|
|
it('initializes timeline as an empty frozen array', () => {
|
|
const s = newSession();
|
|
expect(s.timeline).toEqual([]);
|
|
expect(Object.isFrozen(s.timeline)).toBe(true);
|
|
});
|
|
|
|
it('initializes stuckNotified as false', () => {
|
|
const s = newSession();
|
|
expect(s.stuckNotified).toBe(false);
|
|
});
|
|
|
|
it('initializes telemetry as null', () => {
|
|
const s = newSession();
|
|
expect(s.telemetry).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── onData stuck re-arming (A5 / §3.4) ───────────────────────────────────────
|
|
describe('onData stuck re-arming', () => {
|
|
it('resets stuckNotified to false on any output', () => {
|
|
const s = newSession();
|
|
s.stuckNotified = true; // simulate a stuck alert having fired this round
|
|
(s.pty as MockIPty).emitData('new output');
|
|
expect(s.stuckNotified).toBe(false);
|
|
});
|
|
|
|
it('resets claudeStatus from stuck→working and broadcasts when output arrives', () => {
|
|
const s = newSession();
|
|
const ws = createMockWs();
|
|
attachWs(s, ws);
|
|
ws.sent.length = 0;
|
|
|
|
s.claudeStatus = 'stuck';
|
|
(s.pty as MockIPty).emitData('recovered output');
|
|
|
|
expect(s.claudeStatus).toBe('working');
|
|
const msgs = received(ws);
|
|
const statusMsg = msgs.find((m) => m.type === 'status');
|
|
expect(statusMsg).toEqual({ type: 'status', status: 'working' });
|
|
});
|
|
|
|
it('does NOT emit a status message when claudeStatus is not stuck', () => {
|
|
const s = newSession();
|
|
const ws = createMockWs();
|
|
attachWs(s, ws);
|
|
ws.sent.length = 0;
|
|
|
|
s.claudeStatus = 'working'; // not stuck
|
|
(s.pty as MockIPty).emitData('normal chunk');
|
|
|
|
const msgs = received(ws);
|
|
const statusMsgs = msgs.filter((m) => m.type === 'status');
|
|
expect(statusMsgs).toHaveLength(0);
|
|
});
|
|
|
|
it('does NOT emit a status message for idle/unknown/waiting (only stuck → working)', () => {
|
|
for (const status of ['idle', 'unknown', 'waiting'] as const) {
|
|
nextPty = createMockPty();
|
|
const s = createSession(CFG, DIMS, 1_000, () => {});
|
|
const ws = createMockWs();
|
|
attachWs(s, ws);
|
|
ws.sent.length = 0;
|
|
|
|
s.claudeStatus = status;
|
|
(s.pty as MockIPty).emitData('chunk');
|
|
|
|
const msgs = received(ws);
|
|
const statusMsgs = msgs.filter((m) => m.type === 'status');
|
|
expect(statusMsgs).toHaveLength(0);
|
|
}
|
|
});
|
|
|
|
it('broadcasts stuck reset even with no clients attached (no-op, does not throw)', () => {
|
|
const s = newSession();
|
|
// no clients attached
|
|
s.claudeStatus = 'stuck';
|
|
expect(() => (s.pty as MockIPty).emitData('silent recovery')).not.toThrow();
|
|
expect(s.claudeStatus).toBe('working');
|
|
expect(s.stuckNotified).toBe(false);
|
|
});
|
|
|
|
it('output message is still broadcast after stuck re-arm', () => {
|
|
const s = newSession();
|
|
const ws = createMockWs();
|
|
attachWs(s, ws);
|
|
ws.sent.length = 0;
|
|
|
|
s.claudeStatus = 'stuck';
|
|
(s.pty as MockIPty).emitData('hello after stuck');
|
|
|
|
const msgs = received(ws);
|
|
const outputMsgs = msgs.filter((m) => m.type === 'output');
|
|
expect(outputMsgs).toHaveLength(1);
|
|
expect(outputMsgs[0]).toEqual({ type: 'output', data: 'hello after stuck' });
|
|
});
|
|
});
|