diff --git a/docs/adrs/013.client.cursor-style.md b/docs/adrs/013.client.cursor-style.md index 69bb5fa..7ada93d 100644 --- a/docs/adrs/013.client.cursor-style.md +++ b/docs/adrs/013.client.cursor-style.md @@ -36,9 +36,9 @@ The intercept lives in `src/client/cursor.ts`, isolated from the WebSocket and t ## Considered Options -**Option A: Patch ghostty-web** +**Option A: Fix ghostty-web upstream (long-term)** -ghostty-web would need to call `ghostty_render_state_get` with key `cursor_visual_style` (data key 10) after each `write()`, diff the result against the last known style, and call `renderer.setCursorStyle()` on change. This is the correct long-term fix but requires a PR to an external repo and a version bump. The client-side intercept is an equivalent workaround that can be removed once upstream ships it. +`GhosttyTerminal.getCursor()` hardcodes `style: 'block'` instead of reading `cursor_visual_style` from the WASM render state. That is the correct permanent fix. See [Fix to ghostty-web](#fix-to-ghostty-web). The client-side intercept is an equivalent workaround that can be removed once upstream ships it. **Option B: Parse DECSCUSR in the server WebSocket handler** @@ -55,6 +55,45 @@ Rejected — vim in normal mode showing a bar cursor is confusing. The shell def - The intercept adds one regex scan per WebSocket message. DECSCUSR sequences are rare (only on mode change), so the scan almost always yields zero matches and exits immediately. - When ghostty-web adds native DECSCUSR support, `cursor.ts` and the `applyDecscusr` call in `index.ts` can be deleted with no other changes. +## Fix to ghostty-web + +### What the bug is + +`GhosttyTerminal.getCursor()` in `lib/ghostty.ts` hardcodes `style: 'block'` with a TODO comment instead of reading `visual_style` from the WASM render state. The Ghostty WASM binary processes DECSCUSR correctly and updates `RenderState.Cursor.visual_style`, but the JS wrapper never reads it back. So the renderer never reflects cursor style changes from PTY output. + +### What to change + +`getCursor()` should read `cursor_visual_style` from the WASM render state and map it to the renderer's style strings. The WASM API already exposes this via `ghostty_render_state_get`: + +```ts +// lib/ghostty.ts — GhosttyTerminal.getCursor() +getCursor(): RenderStateCursor { + // current (wrong): + return { + x: ..., y: ..., visible: ..., blinking: ..., + style: 'block', // ← hardcoded TODO + }; + + // fix: read visual_style from WASM render state + // data key 10 = cursor_visual_style (0=block, 1=underline, 2=bar) + const visualStyle = this.exports.ghostty_render_state_get(this.handle, 10); + const style = visualStyle === 2 ? 'bar' : visualStyle === 1 ? 'underline' : 'block'; + return { x: ..., y: ..., visible: ..., blinking: ..., style }; +} +``` + +After each `write()`, the `Terminal` render loop calls `getCursor()` and passes the result to `renderer.setCursorStyle()` — so fixing `getCursor()` is sufficient; no other changes are needed. + +### Why this is the right fix (not the webtty workaround) + +The webtty workaround parses DECSCUSR escape sequences in the client before they reach `term.write()`, then sets `term.options.cursorStyle` directly. The ghostty-web fix reads cursor style from the WASM state after `write()` — the same data, the correct layer. With this upstream fix, `cursor.ts` and the `applyDecscusr` call in `index.ts` can be deleted entirely. + +### Contribution checklist + +- [ ] Open issue: "DECSCUSR cursor style not applied — `getCursor()` hardcodes `style: 'block'`" +- [ ] PR: `lib/ghostty.ts` `GhosttyTerminal.getCursor()` — read `cursor_visual_style` (data key 10) from WASM render state +- [ ] Test: `write('\x1b[6 q')` (bar, no blink) → assert `getCursor().style === 'bar'` + ## Related Decisions - [ADR 010 — Client UX polish](010.client.ux-polish.md): established the WebSocket message handling in `index.ts` that this intercept hooks into diff --git a/docs/adrs/014.client.image-paste.md b/docs/adrs/014.client.image-paste.md new file mode 100644 index 0000000..5f9353f --- /dev/null +++ b/docs/adrs/014.client.image-paste.md @@ -0,0 +1,113 @@ +# ADR 014: webtty — Non-text paste via Ctrl+V PTY forwarding + +**SPEC:** [client](../specs/client.md) +**Status:** Accepted +**Date:** 2026-03-26 + +--- + +## Context + +Pasting non-text content (e.g. an image) into the webtty browser terminal silently does nothing. Text paste works fine. The same setup works correctly under ttyd (xterm.js), so the issue is specific to ghostty-web's input handling. + +### Root cause + +ghostty-web's `InputHandler.handleKeyDown` unconditionally swallows Ctrl+V / Cmd+V without forwarding `\x16` to the PTY: + +```js +if ((A.ctrlKey || A.metaKey) && A.code === "KeyV") + return; // Ctrl+V keydown never reaches the PTY +``` + +It relies entirely on the browser `paste` event. xterm.js (used by ttyd) does not do this — Ctrl+V propagates to the PTY as `\x16` regardless of clipboard content. + +For text paste this is fine: the `paste` event fires and ghostty-web's `handlePaste` extracts `text/plain` and sends it. But when the clipboard has no `text/plain` (image, file, etc.), `handlePaste` silently drops the event — the PTY sees nothing at all. + +### Why the Ctrl+V keydown matters + +opencode's native TUI detects `\x16` from the PTY and calls the **native OS clipboard API** directly — it does not use the browser clipboard event: + +- macOS: `osascript` reads the clipboard as PNG +- Windows: `powershell.exe System.Windows.Forms.Clipboard.GetImage()` +- Linux: `wl-paste` / `xclip` + +Because ghostty-web never sends `\x16` to the PTY, opencode's handler never fires. ttyd works because xterm.js forwards the keydown. + +## Decision + +Add a capture-phase `paste` listener on the terminal container in `src/client/index.ts`. When `clipboardData` has no `text/plain` — the exact condition where ghostty-web would drop the paste: + +1. `preventDefault()` — prevents the browser from inserting the content into the DOM +2. `stopImmediatePropagation()` — prevents ghostty-web's `handlePaste` from running +3. `ws.send('\x16')` — forwards Ctrl+V to the PTY so TUI apps can read the clipboard natively + +When `text/plain` is present the listener returns immediately — ghostty-web handles it, avoiding a double-paste. + +### Sequence of events + +``` +user presses Ctrl+V with non-text content (e.g. image) in clipboard + → ghostty-web keydown: swallows Ctrl+V, \x16 never sent to PTY + → browser fires paste event + → capture-phase listener (our code): + text/plain present → return, ghostty-web handles normally + no text/plain → preventDefault + stopImmediatePropagation + ws.send('\x16') + → PTY receives \x16 + → opencode: Clipboard.read() → osascript/powershell/wl-paste → [Image 1] ✅ +``` + +## Considered Options + +### Option A: Capture-phase listener, send `\x16` when no `text/plain` (chosen) + +~12 lines in `src/client/index.ts`. No server changes. Triggers exactly when ghostty-web would drop the paste — no double-paste for text, generalises to any non-text clipboard content. + +### Option B: Always send `\x16` on every paste + +Causes double-paste for text: PTY receives both `\x16` and the text from ghostty-web's handler. Rejected. + +### Option C: Fix ghostty-web upstream (long-term) + +The correct permanent fix. See [Fix to ghostty-web](#fix-to-ghostty-web). + +## Consequences + +- Non-text paste works in opencode and any TUI that reads the OS clipboard on `\x16` +- Text paste is entirely unaffected +- TUI apps that do not handle `\x16` for clipboard are unaffected — same as baseline + +## Fix to ghostty-web + +### What the bug is + +`InputHandler.handleKeyDown` in `lib/input-handler.ts` returns early on Ctrl+V without emitting `\x16` to `onDataCallback`. xterm.js forwards the keydown; ghostty-web does not. This breaks any TUI that handles clipboard access via `\x16` in the PTY stream. + +### What to change + +Emit the encoded keydown before returning, so `\x16` reaches the PTY. The `paste` event still fires afterwards, so text paste via `handlePaste` is unaffected. + +```ts +// lib/input-handler.ts — InputHandler.handleKeyDown +if ((event.ctrlKey || event.metaKey) && event.code === 'KeyV') { + const encoded = this.encoder.encode({ key: Key.V, mods: Mods.CTRL, action: KeyAction.PRESS }); + if (encoded.length > 0) { + this.onDataCallback(new TextDecoder().decode(encoded)); // \x16 → PTY + } + return; // browser paste event fires next → handlePaste covers text +} +``` + +### Why this is the right fix (not the webtty workaround) + +The webtty workaround intercepts the `paste` event to infer that Ctrl+V was pressed. The ghostty-web fix sends `\x16` from the `keydown` handler where it belongs — the same place xterm.js does it. With this upstream fix, the webtty workaround can be removed entirely. + +### Contribution checklist + +- [ ] Open issue: "Ctrl+V keydown not forwarded to PTY — breaks TUI clipboard handling (e.g. opencode)" +- [ ] PR: `lib/input-handler.ts` `handleKeyDown` — add `onDataCallback(\x16)` before early return +- [ ] Test: `keydown` `ctrlKey=true, code='KeyV'` → assert `onDataCallback` receives `\x16` + +## Related Decisions + +- [ADR 010 — Client UX polish](010.client.ux-polish.md): established the pattern of the client handling terminal I/O edge cases diff --git a/docs/adrs/015.ordering-system.pricing-storage.md b/docs/adrs/015.ordering-system.pricing-storage.md new file mode 100644 index 0000000..48bc6ad --- /dev/null +++ b/docs/adrs/015.ordering-system.pricing-storage.md @@ -0,0 +1,90 @@ +--- +status: accepted +date: 2026-03-26 +decision-makers: Product Engineering Team, Platform Team +consulted: Product Team, Client Application Teams +informed: Engineering Leadership, SRE/Operations Team +--- + +# Pricing Storage for Ordering System + +## Context and Problem Statement + +The Ordering System serves multiple clients that require accurate, real-time pricing information. Pricing is updated frequently by the Product team and must propagate to all clients immediately. We need a centralized pricing store that is consistent, highly available, low-latency, and scalable. + +## Decision Drivers + +* Strong consistency — all clients must see the same price at all times +* Real-time propagation — changes must reach clients with no observable delay +* High availability — no single point of failure +* Low-latency reads — sub-100ms for real-time ordering workflows +* Durability and scalability as client traffic grows + +## Considered Options + +* PostgreSQL with Redis Cache +* PostgreSQL Only +* Elasticsearch with PostgreSQL +* AWS DynamoDB + +## Decision Outcome + +Chosen option: **PostgreSQL with Redis Cache**, because it is the only option that meets all three critical requirements together: strong consistency (PostgreSQL ACID), real-time propagation (LISTEN/NOTIFY + cache invalidation), and low-latency reads (Redis sub-10ms) — without vendor lock-in. + +### Consequences + +* Good, because ACID guarantees ensure consistent, durable pricing data across all clients +* Good, because LISTEN/NOTIFY enables real-time propagation without a separate message broker +* Good, because Redis cache delivers sub-10ms reads, well within the 100ms SLA +* Bad, because two systems increase operational complexity +* Bad, because cache invalidation requires careful implementation to avoid serving stale prices + +### Confirmation + +* Integration tests: write a price update, verify all client read paths return the new value within SLA +* Load tests: p99 read latency must stay below 100ms at peak traffic +* Production alert: Redis cache hit ratio below 80% or cache staleness above threshold triggers PagerDuty + +## Pros and Cons of the Options + +### PostgreSQL with Redis Cache + +Write-through to PostgreSQL; Redis as cache. LISTEN/NOTIFY broadcasts changes; clients invalidate and re-fetch. + +* Good, because ACID consistency and durability +* Good, because sub-10ms cache reads meet latency SLA +* Good, because LISTEN/NOTIFY propagates changes in real time without extra infrastructure +* Bad, because two systems to operate and monitor +* Bad, because cache invalidation bugs can cause stale price reads + +### PostgreSQL Only + +All reads and writes go directly to PostgreSQL. + +* Good, because simple — one system, no cache consistency issues +* Bad, because read latency (5–50ms) may breach SLA under peak load +* Bad, because becomes a read bottleneck as client count grows; read replicas introduce replication lag + +### Elasticsearch with PostgreSQL + +PostgreSQL as source of truth; Elasticsearch indexed via background sync for reads. + +* Good, because fast and scalable reads for complex queries +* Bad, because background sync means updates are not real-time — violates the propagation requirement +* Bad, because significant operational overhead for a simple key-value pricing lookup pattern + +### AWS DynamoDB + +DynamoDB as primary store with Streams for change propagation. + +* Good, because fully managed with built-in HA and auto-scaling +* Bad, because eventual consistency by default; strong consistency costs more +* Bad, because vendor lock-in and unpredictable cost at scale + +## More Information + +Change propagation flow: write to PostgreSQL → invalidate Redis key → publish on `pricing_updates` LISTEN/NOTIFY channel → clients refresh from cache. + +Key risks: cache stampede (mitigate with short TTLs + request coalescing), stale data on invalidation failure (mitigate with 5–10 min TTL safety net), Redis node loss (mitigate with Redis Cluster; cache rebuilds from PostgreSQL). + +Revisit if the system expands to 10+ regions or pricing model complexity requires full-text search. diff --git a/docs/specs/client.md b/docs/specs/client.md index 37fb55a..c7ac1ab 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -1,7 +1,7 @@ # SPEC: Client **Author:** jesse23 -**Last Updated:** 2026-03-24 +**Last Updated:** 2026-03-26 --- @@ -129,3 +129,4 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Welcome banner and status messages | `[ webtty ]`-styled banner on first connect; consistent status messages for disconnect, error, and server stop | [ADR 010](../adrs/010.client.ux-polish.md) | ✅ | | Copy behavior | `copyOnSelect` + `rightClickBehavior` — two independent configurable copy modes | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | | Cursor style | `cursorStyle` / `cursorStyleBlink` defaults; DECSCUSR from PTY overrides at runtime via client-side intercept | [ADR 013](../adrs/013.client.cursor-style.md) | ✅ | +| Non-text paste | Ctrl+V with no `text/plain` in clipboard forwards `\x16` to PTY; TUI apps read non-text content via their native OS clipboard API | [ADR 014](../adrs/014.client.image-paste.md) | ✅ | diff --git a/src/client/cursor.test.ts b/src/client/cursor.test.ts new file mode 100644 index 0000000..37fa3c5 --- /dev/null +++ b/src/client/cursor.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from 'bun:test'; +import { applyDecscusr } from './cursor'; + +function makeTerm(): { options: { cursorStyle: string; cursorBlink: boolean } } { + return { options: { cursorStyle: 'block', cursorBlink: false } }; +} + +describe('applyDecscusr', () => { + test('Ps 0 — default reset — sets block blinking', () => { + const term = makeTerm(); + applyDecscusr(term as never, '\x1b[0 q'); + expect(term.options.cursorStyle).toBe('block'); + expect(term.options.cursorBlink).toBe(true); + }); + + test('Ps 1 — blinking block', () => { + const term = makeTerm(); + applyDecscusr(term as never, '\x1b[1 q'); + expect(term.options.cursorStyle).toBe('block'); + expect(term.options.cursorBlink).toBe(true); + }); + + test('Ps 2 — steady block', () => { + const term = makeTerm(); + applyDecscusr(term as never, '\x1b[2 q'); + expect(term.options.cursorStyle).toBe('block'); + expect(term.options.cursorBlink).toBe(false); + }); + + test('Ps 3 — blinking underline', () => { + const term = makeTerm(); + applyDecscusr(term as never, '\x1b[3 q'); + expect(term.options.cursorStyle).toBe('underline'); + expect(term.options.cursorBlink).toBe(true); + }); + + test('Ps 4 — steady underline', () => { + const term = makeTerm(); + applyDecscusr(term as never, '\x1b[4 q'); + expect(term.options.cursorStyle).toBe('underline'); + expect(term.options.cursorBlink).toBe(false); + }); + + test('Ps 5 — blinking bar', () => { + const term = makeTerm(); + applyDecscusr(term as never, '\x1b[5 q'); + expect(term.options.cursorStyle).toBe('bar'); + expect(term.options.cursorBlink).toBe(true); + }); + + test('Ps 6 — steady bar', () => { + const term = makeTerm(); + applyDecscusr(term as never, '\x1b[6 q'); + expect(term.options.cursorStyle).toBe('bar'); + expect(term.options.cursorBlink).toBe(false); + }); + + test('empty Ps defaults to 0 (blinking block)', () => { + const term = makeTerm(); + applyDecscusr(term as never, '\x1b[ q'); + expect(term.options.cursorStyle).toBe('block'); + expect(term.options.cursorBlink).toBe(true); + }); + + test('out-of-range Ps is ignored', () => { + const term = makeTerm(); + term.options.cursorStyle = 'bar'; + term.options.cursorBlink = false; + applyDecscusr(term as never, '\x1b[7 q'); + expect(term.options.cursorStyle).toBe('bar'); + expect(term.options.cursorBlink).toBe(false); + }); + + test('no DECSCUSR sequence leaves options unchanged', () => { + const term = makeTerm(); + applyDecscusr(term as never, 'hello world'); + expect(term.options.cursorStyle).toBe('block'); + expect(term.options.cursorBlink).toBe(false); + }); + + test('multiple sequences in one chunk — last one wins', () => { + const term = makeTerm(); + applyDecscusr(term as never, '\x1b[2 q some output \x1b[5 q'); + expect(term.options.cursorStyle).toBe('bar'); + expect(term.options.cursorBlink).toBe(true); + }); + + test('sequence embedded in regular output', () => { + const term = makeTerm(); + applyDecscusr(term as never, 'text before\x1b[6 qtext after'); + expect(term.options.cursorStyle).toBe('bar'); + expect(term.options.cursorBlink).toBe(false); + }); +}); diff --git a/src/client/index.ts b/src/client/index.ts index df08d2d..53d61b8 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -108,22 +108,26 @@ function connect(): void { connect(); +// Forward terminal keystrokes and input to the PTY over WebSocket. term.onData((data: string) => { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(data); } }); +// Notify the server when the terminal is resized so the PTY dimensions stay in sync. term.onResize(({ cols, rows }: { cols: number; rows: number }) => { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'resize', cols, rows })); } }); +// Refit the terminal whenever the browser window is resized. window.addEventListener('resize', () => { fitAddon.fit(); }); +// Copy the selected text to the clipboard whenever the selection changes. if (config.copyOnSelect) { term.onSelectionChange(() => { const selection = term.getSelection() as string; @@ -134,6 +138,7 @@ if (config.copyOnSelect) { }); } +// Copy selected text to clipboard on right-click when copyPaste mode is active. if (config.rightClickBehavior === 'copyPaste') { container.addEventListener('contextmenu', (e: MouseEvent) => { const selection = term.getSelection() as string; @@ -145,3 +150,22 @@ if (config.rightClickBehavior === 'copyPaste') { term.clearSelection(); }); } + +// ghostty-web swallows Ctrl+V without sending \x16 to the PTY (unlike +// xterm.js). When clipboard has no text/plain, its paste handler drops it +// too. Send \x16 so TUI apps can invoke their native OS clipboard read. +// See ADR 014. +container.addEventListener( + 'paste', + (e: ClipboardEvent) => { + const cd = e.clipboardData; + if (!cd) return; + if (cd.getData('text/plain')) return; + e.preventDefault(); + e.stopImmediatePropagation(); + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send('\x16'); + } + }, + { capture: true }, +); diff --git a/src/pty/index.test.ts b/src/pty/index.test.ts index ba78852..51ef601 100644 --- a/src/pty/index.test.ts +++ b/src/pty/index.test.ts @@ -1,15 +1,21 @@ import { describe, expect, test } from 'bun:test'; import { spawnForSession } from './index'; +function waitForData(received: string[], content: string, timeout = 3000): Promise { + return new Promise((resolve, reject) => { + const deadline = Date.now() + timeout; + const check = () => { + if (received.join('').includes(content)) return resolve(); + if (Date.now() > deadline) return reject(new Error(`Timeout waiting for: ${content}`)); + setTimeout(check, 50); + }; + check(); + }); +} + describe('spawnForSession', () => { test('returns a PtyProcess with the expected interface', () => { - const pty = spawnForSession( - 80, - 24, - process.env.SHELL ?? '/bin/sh', - 'xterm-256color', - 'truecolor', - ); + const pty = spawnForSession(80, 24, '/bin/sh', 'xterm-256color', 'truecolor'); expect(typeof pty.onData).toBe('function'); expect(typeof pty.onExit).toBe('function'); @@ -22,19 +28,14 @@ describe('spawnForSession', () => { }); test('spawned process can receive data', async () => { - const pty = spawnForSession( - 80, - 24, - process.env.SHELL ?? '/bin/sh', - 'xterm-256color', - 'truecolor', - ); + const pty = spawnForSession(80, 24, '/bin/sh', 'xterm-256color', 'truecolor'); const received: string[] = []; pty.onData((data) => received.push(data)); - await Bun.sleep(800); + pty.write('echo __ready__\n'); + await waitForData(received, '__ready__'); pty.write('echo hello-pty\n'); - await Bun.sleep(800); + await waitForData(received, 'hello-pty'); pty.write('exit\n'); await new Promise((resolve) => pty.onExit(() => resolve())); diff --git a/src/server/websocket.test.ts b/src/server/websocket.test.ts index 72d416d..fd60510 100644 --- a/src/server/websocket.test.ts +++ b/src/server/websocket.test.ts @@ -34,6 +34,32 @@ function waitForMessages(messages: string[], count: number, timeout = 3000): Pro }); } +function waitForPrompt(messages: string[], timeout = 3000): Promise { + return new Promise((resolve, reject) => { + const deadline = Date.now() + timeout; + const check = () => { + const all = messages.join(''); + if (all.includes('\x1b]133;B') || all.match(/[$%#>➜] *$/m)) return resolve(); + if (Date.now() > deadline) return reject(new Error('Timeout waiting for shell prompt')); + setTimeout(check, 50); + }; + check(); + }); +} + +function waitForContent(messages: string[], content: string, timeout = 3000): Promise { + return new Promise((resolve, reject) => { + const deadline = Date.now() + timeout; + const check = () => { + if (messages.join('').includes(content)) return resolve(); + if (Date.now() > deadline) + return reject(new Error(`Timeout waiting for content: ${content}`)); + setTimeout(check, 50); + }; + check(); + }); +} + function closeWs(ws: WebSocket): Promise { return new Promise((resolve) => { if (ws.readyState === WebSocket.CLOSED) return resolve(); @@ -55,7 +81,7 @@ describe('websocket', () => { baseUrl = `http://127.0.0.1:${port}`; wsBase = `ws://127.0.0.1:${port}`; proc = spawn(process.execPath, [SERVER_ENTRY], { - env: { ...process.env, PORT: String(port), HOME: tmpHome }, + env: { ...process.env, PORT: String(port), HOME: tmpHome, SHELL: '/bin/sh' }, stdio: 'ignore', }); await waitForServer(baseUrl); @@ -127,20 +153,17 @@ describe('websocket', () => { ); await waitForMessages(m2, 1); - await Bun.sleep(2500); - - const before1 = m1.length; - const before2 = m2.length; + await waitForPrompt(m1); ws1.send('echo hello-fanout\n'); - await waitForMessages(m1, before1 + 1); - await waitForMessages(m2, before2 + 1); + await waitForContent(m1, 'hello-fanout'); + await waitForContent(m2, 'hello-fanout'); await closeWs(ws1); await closeWs(ws2); - expect(m1.slice(before1).join('')).toContain('hello-fanout'); - expect(m2.slice(before2).join('')).toContain('hello-fanout'); + expect(m1.join('')).toContain('hello-fanout'); + expect(m2.join('')).toContain('hello-fanout'); }); test('session is removed and tab closed when shell exits', async () => { @@ -173,13 +196,12 @@ describe('websocket', () => { ws.send(JSON.stringify({ type: 'resize', cols: 120, rows: 40 })); - await Bun.sleep(2500); - const before = messages.length; + await waitForPrompt(messages); ws.send('echo resize-ok\n'); - await waitForMessages(messages, before + 1); + await waitForContent(messages, 'resize-ok'); await closeWs(ws); - expect(messages.slice(before).join('')).toContain('resize-ok'); + expect(messages.join('')).toContain('resize-ok'); }); test('server shuts down when last session exits', async () => {