From 5d2aa492c9337034369287277d7712c32455f321 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 29 Oct 2026 23:03:59 +0000 Subject: [PATCH 1/2] test: cover encodeMouse getters that change between reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A getter can return one value to the TypeScript checks and another to the addon. This test fails until the normalizers read each field once. Signed-off-by: Thomas Kosiewski --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `medium`_ Change-Id: Id19dd86ea228c68d85b420216eaa2f4cb43d1621 --- test/terminal.test.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/test/terminal.test.ts b/test/terminal.test.ts index e28215a..8c8f4b8 100644 --- a/test/terminal.test.ts +++ b/test/terminal.test.ts @@ -254,6 +254,38 @@ describeIfNative("GhosttyVtTerminal", () => { } }); + it("encodes the event and geometry values it validated when getters change", () => { + const term = createTerminal({ cols: 80, rows: 24 }); + try { + term.feed("\x1b[?1000h\x1b[?1006h"); + const expected = Buffer.from("\x1b[<0;4;4M"); + + let xReads = 0; + const event = { + action: "press", + button: "left", + get x() { + return xReads++ === 0 ? 3 : 1e30; + }, + y: 3, + } as const; + expect(term.encodeMouse(event, { geometry: unitGeometry })).toEqual(expected); + + let widthReads = 0; + const geometry = { + ...unitGeometry, + get screenWidth() { + return widthReads++ === 0 ? 80 : 0; + }, + }; + expect( + term.encodeMouse({ action: "press", button: "left", x: 3, y: 3 }, { geometry }), + ).toEqual(expected); + } finally { + term.dispose(); + } + }); + it("covers button tracking, legacy releases, X10 limits, and UTF-8 coordinates", () => { const cases = [ { mode: "\x1b[?1002h\x1b[?1006h", action: "motion", button: "left", x: 2, expected: Buffer.from("\x1b[<32;3;4M") }, From 01f45763602cc9c22f1c9384995fc6e0fdc03b37 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 29 Oct 2026 23:04:50 +0000 Subject: [PATCH 2/2] fix(src): read each encodeMouse field once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The normalizers destructure each caller field into a local, then validate and forward those locals. Error types, messages and check order stay the same. Signed-off-by: Thomas Kosiewski --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `medium`_ Change-Id: Ia074be12cc33f616d1762b28f4e8d514e0652039 --- src/README.md | 2 +- src/index.ts | 94 ++++++++++++++++++++++++++++----------------------- 2 files changed, 52 insertions(+), 44 deletions(-) diff --git a/src/README.md b/src/README.md index 5bc3498..9b5ce87 100644 --- a/src/README.md +++ b/src/README.md @@ -40,7 +40,7 @@ Internal (not exported by the package): - `encodeMouse` checks the options, then the event. `options`, `geometry`, the event, and `modifiers` (when defined) must be non-array objects (`TypeError(" must be an object")`). The four geometry sizes must be positive integers and the paddings non-negative integers, with the `TypeError` messages of `assertPositiveInteger` and `assertNonNegativeInteger` and the name prefix `mouse geometry.`. A field above 2^32-1 throws `RangeError("mouse geometry. exceeds the 32-bit limit")` [src/index.ts:normalizeMouseOptions]. - `encodeMouse` repeats the native padding, cell-count, and coordinate-range limits with the same three messages: `"mouse geometry padding exceeds screenWidth"`, `"mouse geometry.screenWidth exceeds the 65535-cell limit"`, and `"mouse x is outside Ghostty's cell or pixel coordinate range"` (all `RangeError`) [src/index.ts:assertMouseAxis] [src/index.ts:assertMouseCoordinate]. `src/` has no f32-range check: for `x: 1e39` it throws the coordinate-range error, where the addon throws `"mouse x must be a finite 32-bit float"`. The native layer checks every limit again, and adds the UTF-8 check, which needs the terminal's modes [native/terminal.cc:TerminalWrap::EncodeMouse]. - `encodeMouse` event: an unknown `action` or `button` throws `TypeError("mouse action is invalid")` or `"mouse button is invalid"`, a `press` or `release` without `button` throws `TypeError(" mouse event requires a button")`, a non-finite `x` or `y` throws `TypeError("mouse x must be a finite number")`, and a non-boolean modifier, `anyButtonPressed`, or `trackLastCell` throws `TypeError(" must be a boolean")` [src/index.ts:normalizeMouseEvent] [src/index.ts:optionalBoolean]. -- The addon receives new objects: the event with `action`, `x`, `y`, `button` only when given, and `modifiers` only when given (with all three booleans), and the options with all eight geometry fields (missing paddings as 0), `anyButtonPressed`, and `trackLastCell` [src/index.ts:normalizeMouseEvent] [src/index.ts:normalizeMouseOptions]. +- The addon receives new objects: the event with `action`, `x`, `y`, `button` only when given, and `modifiers` only when given (with all three booleans), and the options with all eight geometry fields (missing paddings as 0), `anyButtonPressed`, and `trackLastCell`. Each caller field is read once, so a getter cannot pass the checks with one value and send the addon another [src/index.ts:normalizeMouseEvent] [src/index.ts:normalizeMouseOptions]. - `feed` accepts a string or a `Uint8Array` (a `Buffer` is one). Any other value throws `TypeError("feed data must be a string, Buffer, or Uint8Array")` [src/index.ts:assertFeedData]. - The `Terminal` constructor asserts that the addon returned a non-null object (`"native terminal must be an object"`, `"native terminal must not be null"`) [src/index.ts:Terminal]. - After `dispose()`, `feed`, `resize`, `encodeMouse`, `snapshot`, `getVisibleText`, `formatPlain`, and `formatHtml` throw `Error("GhosttyVtTerminal has been disposed")`. The disposed check runs before the argument checks [src/index.ts:Terminal]. diff --git a/src/index.ts b/src/index.ts index 6060a0f..bcfe1b3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -118,34 +118,36 @@ function optionalBoolean(name: string, value: unknown): boolean { // that Ghostty's mouse event model can represent. function normalizeMouseEvent(event: MouseInputEvent, geometry: MouseGeometry): MouseInputEvent { assertObject("mouse event", event); - if (!mouseActions.has(event.action)) throw new TypeError("mouse action is invalid"); - if (event.button !== undefined && !mouseButtons.has(event.button)) { + const { action, button, x, y, modifiers } = event; + if (!mouseActions.has(action)) throw new TypeError("mouse action is invalid"); + if (button !== undefined && !mouseButtons.has(button)) { throw new TypeError("mouse button is invalid"); } - if (event.action !== "motion" && event.button === undefined) { - throw new TypeError(`${event.action} mouse event requires a button`); + if (action !== "motion" && button === undefined) { + throw new TypeError(`${action} mouse event requires a button`); } - assertFiniteNumber("mouse x", event.x); - assertFiniteNumber("mouse y", event.y); - assertMouseCoordinate("mouse x", event.x, geometry.paddingLeft ?? 0, geometry.cellWidth); - assertMouseCoordinate("mouse y", event.y, geometry.paddingTop ?? 0, geometry.cellHeight); - - let modifiers: MouseModifiers | undefined; - if (event.modifiers !== undefined) { - assertObject("mouse modifiers", event.modifiers); - modifiers = { - shift: optionalBoolean("mouse modifiers.shift", event.modifiers.shift), - ctrl: optionalBoolean("mouse modifiers.ctrl", event.modifiers.ctrl), - alt: optionalBoolean("mouse modifiers.alt", event.modifiers.alt), + assertFiniteNumber("mouse x", x); + assertFiniteNumber("mouse y", y); + assertMouseCoordinate("mouse x", x, geometry.paddingLeft ?? 0, geometry.cellWidth); + assertMouseCoordinate("mouse y", y, geometry.paddingTop ?? 0, geometry.cellHeight); + + let normalizedModifiers: MouseModifiers | undefined; + if (modifiers !== undefined) { + assertObject("mouse modifiers", modifiers); + const { shift, ctrl, alt } = modifiers; + normalizedModifiers = { + shift: optionalBoolean("mouse modifiers.shift", shift), + ctrl: optionalBoolean("mouse modifiers.ctrl", ctrl), + alt: optionalBoolean("mouse modifiers.alt", alt), }; } return { - action: event.action, - ...(event.button === undefined ? {} : { button: event.button }), - x: event.x, - y: event.y, - ...(modifiers === undefined ? {} : { modifiers }), + action, + ...(button === undefined ? {} : { button }), + x, + y, + ...(normalizedModifiers === undefined ? {} : { modifiers: normalizedModifiers }), }; } @@ -168,36 +170,42 @@ function assertMouseAxis(name: string, screen: number, cell: number, padding: nu // cell-based and pixel-based terminal protocols. function normalizeMouseOptions(options: MouseEncoderOptions): MouseEncoderOptions { assertObject("mouse encoder options", options); - assertObject("mouse geometry", options.geometry); - const geometry = options.geometry; - assertPositiveInteger("mouse geometry.screenWidth", geometry.screenWidth); - assertPositiveInteger("mouse geometry.screenHeight", geometry.screenHeight); - assertPositiveInteger("mouse geometry.cellWidth", geometry.cellWidth); - assertPositiveInteger("mouse geometry.cellHeight", geometry.cellHeight); - - const normalizedGeometry: MouseGeometry = { - screenWidth: geometry.screenWidth, - screenHeight: geometry.screenHeight, - cellWidth: geometry.cellWidth, - cellHeight: geometry.cellHeight, - }; - for (const key of ["paddingTop", "paddingBottom", "paddingRight", "paddingLeft"] as const) { - const value = geometry[key]; + const { geometry, anyButtonPressed, trackLastCell } = options; + assertObject("mouse geometry", geometry); + const { + screenWidth, screenHeight, cellWidth, cellHeight, + paddingTop, paddingBottom, paddingRight, paddingLeft, + } = geometry; + assertPositiveInteger("mouse geometry.screenWidth", screenWidth); + assertPositiveInteger("mouse geometry.screenHeight", screenHeight); + assertPositiveInteger("mouse geometry.cellWidth", cellWidth); + assertPositiveInteger("mouse geometry.cellHeight", cellHeight); + for (const [key, value] of Object.entries({ paddingTop, paddingBottom, paddingRight, paddingLeft })) { if (value !== undefined) assertNonNegativeInteger(`mouse geometry.${key}`, value); - normalizedGeometry[key] = value ?? 0; } + + const normalizedGeometry = { + screenWidth, + screenHeight, + cellWidth, + cellHeight, + paddingTop: paddingTop ?? 0, + paddingBottom: paddingBottom ?? 0, + paddingRight: paddingRight ?? 0, + paddingLeft: paddingLeft ?? 0, + }; for (const [key, value] of Object.entries(normalizedGeometry)) { if (value > 0xffffffff) throw new RangeError(`mouse geometry.${key} exceeds the 32-bit limit`); } - assertMouseAxis("screenWidth", geometry.screenWidth, geometry.cellWidth, - normalizedGeometry.paddingLeft! + normalizedGeometry.paddingRight!); - assertMouseAxis("screenHeight", geometry.screenHeight, geometry.cellHeight, - normalizedGeometry.paddingTop! + normalizedGeometry.paddingBottom!); + assertMouseAxis("screenWidth", screenWidth, cellWidth, + normalizedGeometry.paddingLeft + normalizedGeometry.paddingRight); + assertMouseAxis("screenHeight", screenHeight, cellHeight, + normalizedGeometry.paddingTop + normalizedGeometry.paddingBottom); return { geometry: normalizedGeometry, - anyButtonPressed: optionalBoolean("anyButtonPressed", options.anyButtonPressed), - trackLastCell: optionalBoolean("trackLastCell", options.trackLastCell), + anyButtonPressed: optionalBoolean("anyButtonPressed", anyButtonPressed), + trackLastCell: optionalBoolean("trackLastCell", trackLastCell), }; }