Skip to content

test: check that encodeMouse reads every caller field once - #48

Merged
ThomasK33 merged 1 commit into
mainfrom
test/encode-mouse-reads-each-field-once
Oct 30, 2026
Merged

ThomasK33 merged 1 commit into
mainfrom
test/encode-mouse-reads-each-field-once

Conversation

@ThomasK33

Copy link
Copy Markdown
Member

Why

The goal is to handle and land the items still open after the #12 follow-ups, one PR each. This PR adds the test coverage for the read-once contract that #41 added.

src/README.md promises that encodeMouse reads each caller field once. The #41 test checks only x and geometry.screenWidth. A later change that reads y or cellWidth twice still passes it.

What changed

test/terminal.test.ts has one new native test, "reads every event, modifier, option, and geometry field once", right after the #41 test.

  1. The test puts a counting getter on all 19 caller fields. Every optional field is set to a valid value, so each one is present.
  2. It calls encodeMouse once and checks the bytes: ESC[<12;4;6M. The SGR rule gives this value: left button 0, plus 4 for shift, plus 8 for alt. Column and row are 1-based (x 3 → 4, y 5 → 6).
  3. It checks the read counts as one equality on a map of field name to count. A failure names each field read more than once.

Scope

Test only. No change to src/ or native/.

Blast Radius

One test in the native suite. It skips with the rest of the suite when the addon is not built.

Verification

I proved the test can fail. I put the parent of #41's fix (git show 30242de^:src/index.ts) into src/index.ts, rebuilt, and ran the test. It fails and names the 10 fields read more than once:

$ npx vitest run test/terminal.test.ts -t "reads every"
 FAIL  test/terminal.test.ts > GhosttyVtTerminal > reads every event, modifier, option, and geometry field once
AssertionError: expected { screenWidth: 3, …(18) } to deeply equal { geometry: 1, …(18) }
-   "action": 1,
+   "action": 3,
-   "button": 1,
-   "cellHeight": 1,
-   "cellWidth": 1,
+   "button": 5,
+   "cellHeight": 3,
+   "cellWidth": 3,
-   "geometry": 1,
-   "modifiers": 1,
+   "geometry": 2,
+   "modifiers": 5,
-   "screenHeight": 1,
-   "screenWidth": 1,
+   "screenHeight": 3,
+   "screenWidth": 3,
-   "x": 1,
-   "y": 1,
+   "x": 3,
+   "y": 3,
      Tests  1 failed | 33 skipped (34)

The other 9 fields showed 1 read each. Then I restored src/index.ts, rebuilt, and ran the test again:

$ npx vitest run test/terminal.test.ts -t "reads every"
 Test Files  1 passed (1)
      Tests  1 passed | 33 skipped (34)

Gates on the final commit. Each one exited 0:

  1. npm ci && LIBGHOSTTY_VT_PREFIX=<ReleaseFast> npm run build:native && npm run build && npm run verify. 78 tests passed.
  2. The same with the ReleaseSafe prefix. 78 tests passed.
  3. UBSan: rm -rf dist && LIBGHOSTTY_VT_PREFIX=<ReleaseFast> CXXFLAGS="-fsanitize=undefined,float-cast-overflow -fno-sanitize-recover=all" LDFLAGS="-fsanitize=undefined,float-cast-overflow" npm run build:native && npm test && npm run smoke. 78 tests passed, smoke passed.

Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: medium

The #41 test counted reads only on x and geometry.screenWidth. This one
puts a counting getter on all 19 caller fields and asserts one read each.

Signed-off-by: Thomas Kosiewski <tk@coder.com>

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `medium`_

Change-Id: Ie3e1dfaad4e4207c7c670bd1ce9cf928549d854e
@ThomasK33

Copy link
Copy Markdown
Member Author

Verdict: PASS
Head: 7e6f936

Parent: 9f8ec06 (main). Ghostty 83fb956 prefixes. Node 26.12.1 inside npm (unset Path, node-gyp logged Node 26.12.1).

Field list. At the head, normalizeMouseOptions destructures geometry, anyButtonPressed, trackLastCell and then screenWidth, screenHeight, cellWidth, cellHeight, paddingTop, paddingBottom, paddingRight, paddingLeft. normalizeMouseEvent destructures action, button, x, y, modifiers and then shift, ctrl, alt. That is 19 fields. They are the same 19 that src/types.ts declares and the same 19 that the test counts. No field is missing and no field is extra. normalizeMouseEvent gets the normalized geometry, not the caller's, so it adds no caller reads. This is the src/README.md promise ("Each caller field is read once").

Expected bytes. I derived them from Ghostty's src/input/mouse_encode.zig at 83fb956. Left button is 0. Mode 1000 is not X10, so shift adds 4 and alt adds 8, which gives 12. Unit cells and zero padding put x 3, y 5 in cell (3, 5). SGR prints cell + 1 and M for a press. The result is ESC[<12;4;6M, which matches the test.

Test fails on the bug it guards.

  • git show 30242de^:src/index.ts > src/index.ts, rebuild, npx vitest run test/terminal.test.ts -t "reads every|getters change": both tests fail. The new test names 10 fields: action 3, button 5, cellHeight 3, cellWidth 3, geometry 2, modifiers 5, screenHeight 3, screenWidth 3, x 3, y 3.
  • Head restored and rebuilt: 2 passed.
  • Second read of cellHeight added in normalizeMouseOptions (assertPositiveInteger("mouse geometry.cellHeight", geometry.cellHeight)): the new test fails with only "cellHeight": 2. The fix(src): read each encodeMouse field once #41 test still passes.
  • Same mutation with the parent's test/terminal.test.ts: npx vitest run gives 77 passed (77). With the head's test file it gives 1 failed | 77 passed (78). The parent suite misses this bug and the head suite catches it.

Package user view. An ES module that imports dist/index.js and puts counting getters on the same 19 fields printed "\u001b[<12;4;6M" and 19 fields; 0 not read exactly once.

Gates at the head (all exit 0):

  • ReleaseFast: npm ci && build:native && build && verify. Typecheck passed, 78 passed (78), smoke passed.
  • ReleaseSafe: same steps. 78 passed (78), smoke passed. The addon has 1 reached unreachable code string, so it links ReleaseSafe.
  • UBSan in CI order: 78 passed (78), smoke passed. The addon objects reference __ubsan_handle_* (addon.o 5, terminal.o 8, libghostty_vt_shim.o 4).

Diff. gh pr diff 48 --name-only lists only test/terminal.test.ts (+40). There are no src/ or native/ changes. The numbers in the PR body match my runs.


Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: xhigh

@ThomasK33
ThomasK33 merged commit 9d5a707 into main Oct 30, 2026
5 checks passed
@ThomasK33
ThomasK33 deleted the test/encode-mouse-reads-each-field-once branch October 30, 2026 07:42
ThomasK33 added a commit that referenced this pull request Oct 30, 2026
## Why
Ship the `snapshot()` crash fix (#46) to npm. Every earlier version, `0.1.0-beta.2` included, crashes Node when a JS accessor on `Object.prototype` disposes the terminal while `snapshot()` builds its result.

## What changed
- `package.json` and `package-lock.json` go from `0.1.0-beta.2` to `0.1.0-beta.3`. The branch name matches the version, so `publish.yml` creates `v0.1.0-beta.3` after merge.
- The `release-changelog` workflow adds the CHANGELOG entry to this branch.
- beta.3 also ships #48 (tests only) and #49 (the raw native addon's `feed` now rejects typed arrays other than `Uint8Array`). The package exports only `createTerminal`'s module, which already rejected those values in JS, so neither gets a CHANGELOG line.
- The beta.2 `## Fixed` heading, which the changelog bot had joined to the previous bullet, is restored.

## Blast Radius
The merge publishes `0.1.0-beta.3` to npm under the `beta` dist-tag. `latest` stays on `0.1.0-beta.0`. Snapshot output is byte-identical to beta.2, as #46's verifier checked.

## Verification
- CI (linux-x64, linux-arm64, linux-x64-ubsan, macos-arm64) passes on head `3365375`. Codex code and security reviews are clean on that head.
- `prepare-release-changelog.mjs` reports `changelog-entry-exists`. `resolve-npm-publish-tag.mjs` prints `beta`. The remote has no `v0.1.0-beta.3` tag.
- An independent verifier built prebuilds, ran `verify:prebuilds` (loads `prebuilds/linux-x64`), packed, and installed into a scratch consumer: `0.1.0-beta.3` loads from `prebuilds/`, and 8 accessor cases that crash beta.2 exit 0. It passed the CHANGELOG at `da96287`.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_
ThomasK33 added a commit that referenced this pull request Oct 30, 2026
## Why
The `verify-native-lifecycle` skill keeps a sync commit. At that commit, its TLA+ model describes the covered native code. The sync commit was still `7366b75` (#12). Since then, #40 to #43, #46, and #49 changed covered code. #46 promised this follow-up.

## What changed
- The sync commit moves to `c54051e`, the current main. It includes #48 and #49.
- The coverage rows now name `ghostty_terminal_set` (scrollback byte limit) in the constructor and its failure path, `Resize`'s second `ghostty_terminal_resize` call, and the `GHOSTTY_TERMINAL_DATA_MODE` read in `EncodeMouse`. Ghostty v1.4.0 (#43) brought these.
- The "no JS inside native methods" assumption now says how each method reads its arguments:
  1. `Snapshot` reads its options before `RequireTerminal`, and it builds its result with `DefineValue` and `DefineIndex`.
  2. `Feed` and `Resize` call `RequireTerminal` first. After that, they only test types and read strings, bytes, and numbers, which runs no JS.
  3. `GetVisibleText`, `FormatPlain`, and `FormatHtml` read no arguments.
  4. The assumption was false for `snapshot` until #46.
- The bullet that lists where caller JS can run now names public `feed`: `assertFeedData` runs `data instanceof Uint8Array`, which runs a Proxy's `getPrototypeOf` trap after `#assertUsable`. The native null check covers it, as it does for `snapshot`.
- `AddonEnvLifecycle.tla` changes in comments only. The `CreateFails` comment now names the `ghostty_terminal_set` failure too.

The model itself does not change. #48 changes only a test. #49 changes only `Feed`'s argument checks, so the `Use(t)` step still describes `feed`.

## Verification
- I checked each row against `native/terminal.cc` and `src/index.ts` at `c54051e`.
- `git diff 7366b75 c54051e` on the covered paths adds no new static state, `Napi::Reference`, or new place that frees `terminal_` or `mouse_encoder_`.
- `run.sh`: all 35 rows match EXPECT (`OK: every result matches EXPECT.`). The current config holds all 5 rules. Each of the 6 mutants breaks the rule it guards.

The verifier's two notes on `0ff0941` (the `feed` window and the `CreateFails` comment) are fixed in `584c561`. `run.sh` still passes all 35 rows.

Workspace 36beaed175 drafted the first version of this commit. I corrected the assumption bullet: it said that every method reads its arguments before `RequireTerminal`, but `Feed` and `Resize` do not.

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant