test(e2e): make IME capture harnesses fail loudly instead of silently

Four instruments recorded silence as success, so a void run scored as a clean
one:

- readTerminalImeBoundaryTrace returned an empty trace when the probe never
  installed, making every "nothing leaked" negative pass vacuously
- summarizeLatencies([]) returned a perfect zero distribution that passed all
  three latency thresholds
- the macOS Vietnamese spec pinned an input-source ID that does not exist, and
  failed as though the operator had chosen the wrong source
- the expectedLineCount=1 prefix property was undocumented and one edit from
  silently downgrading a PTY assertion

Input sources now resolve by enumeration and name the near-matches on failure.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-08-06 00:53:56 -07:00
co-authored by Orca
parent 0dd546a252
commit 48cf6accef
11 changed files with 771 additions and 8 deletions
@@ -185,7 +185,20 @@ function percentile(sorted: number[], quantile: number): number {
return sorted[Math.max(0, rank)]
}
/**
* Why this throws instead of returning zeros: an empty sample set used to summarise as
* `p50/p95/max = 0`, i.e. *perfect* latency, so every `toBeLessThan` threshold passed and a run
* that measured nothing looked like the best run ever recorded. The existing consumer is safe only
* because a separate line asserts the sample count. Refusing here makes that guard unnecessary
* rather than load-bearing.
*/
export function summarizeLatencies(values: number[]): LatencyDistribution {
if (values.length === 0) {
throw new Error(
'summarizeLatencies received 0 samples — a distribution over no data would report 0ms ' +
'and pass every latency threshold. Assert the sample count before summarising.'
)
}
const sorted = [...values].sort((a, b) => a - b)
return {
count: sorted.length,
@@ -0,0 +1,14 @@
// Issue #12164 payloads, emitted as plain agent-style stdout. No IME involved.
const PAYLOADS = [
'프로젝트 브랜딩 이름 확정',
'project branding name',
'项目品牌名称确定',
'проект бренд имя'
]
// Clear + home so every payload starts at column 0 on a known row.
process.stdout.write('\u001b[H\u001b[2J\u001b[3J')
for (const line of PAYLOADS) {
process.stdout.write(`${line}\n`)
}
process.stdout.write('ISSUE_12164_EMIT_DONE\n')
@@ -0,0 +1,347 @@
import { Buffer } from 'node:buffer'
import { PNG } from 'pngjs'
import type { Page } from '@stablyai/playwright-test'
// Issue #12164 reports Korean output rendered/copied as `프프로로젝젝트트`.
// This rig reads one emitted line back through both boundaries that exist:
// the xterm data model and the painted canvas. `getSelection()` is a third
// *read* but not a third *source* — xterm's SelectionService resolves it via
// buffer.translateBufferLineToString, so it shares the data model.
export type PayloadCase = {
id: string
text: string
/** Columns the text must occupy when widths are handled correctly. */
expectedColumns: number
}
export type BufferReading = {
id: string
row: number | null
translateToString: string
selectionText: string
cells: string
cols: number
rows: number
}
export type CanvasReading = {
id: string
row: number
inkExtentColumns: number
inkGroups: string[]
}
export type ProbeGrid = { cols: number; rows: number }
type ProbeCell = { getChars: () => string; getWidth: () => number }
type ProbeLine = {
translateToString: (trimRight?: boolean) => string
getCell: (x: number) => ProbeCell | undefined
}
type ProbePane = {
id: number
container: HTMLElement
terminal: {
cols: number
rows: number
buffer: {
active: {
viewportY: number
getLine: (index: number) => ProbeLine | undefined
}
}
select: (column: number, row: number, length: number) => void
clearSelection: () => void
getSelection: () => string
_core?: {
_renderService?: {
dimensions?: { css?: { cell?: { width: number; height: number } } }
}
}
}
}
declare global {
// oxlint-disable-next-line typescript-eslint/consistent-type-definitions -- declaration merging requires interface
interface Window {
__issue12164FindPane: () => ProbePane
}
}
type RasterTarget = {
clip: { x: number; y: number; width: number; height: number }
cellWidth: number
cellHeight: number
cols: number
rows: number
renderer: 'webgl' | 'dom'
}
export async function readGrid(page: Page): Promise<ProbeGrid> {
return page.evaluate(() => {
const pane = window.__issue12164FindPane()
return { cols: pane.terminal.cols, rows: pane.terminal.rows }
})
}
/**
* Installs the active-pane lookup as a page global so each probe can be a real
* (argument-accepting) evaluate function instead of a string expression.
*/
export async function installPaneLookup(page: Page): Promise<void> {
await page.evaluate(() => {
window.__issue12164FindPane = () => {
const state = window.__store?.getState()
const worktreeId = state?.activeWorktreeId
const tabId =
state?.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
if (!pane?.terminal) {
throw new Error('No active terminal pane')
}
return pane as unknown as ProbePane
}
})
}
/**
* Drops the pane onto the DOM renderer. In-tree comments blame Windows CJK
* "stale wide-glyph cells" on the DOM renderer specifically
* (pty-connection.ts:6228), so WebGL alone does not cover the suspect path.
*/
export async function forceDomRenderer(page: Page): Promise<void> {
// The user-setting gate is the only durable switch: pty-connection re-asserts
// the pane flag from `terminalGpuAcceleration` on every renderer-policy pass,
// so setting the pane directly gets reverted.
await page.evaluate(() => {
const store = window.__store?.getState() as unknown as {
updateSettings?: (patch: Record<string, unknown>) => void
}
if (!store?.updateSettings) {
throw new Error('settings store is unavailable')
}
store.updateSettings({ terminalGpuAcceleration: 'off' })
})
}
/** Waits until the fit addon stops resizing the grid, so emit and read agree. */
export async function waitForStableGrid(page: Page, timeoutMs = 10_000): Promise<ProbeGrid> {
const deadline = Date.now() + timeoutMs
let previous = await readGrid(page)
while (Date.now() < deadline) {
await page.waitForTimeout(300)
const current = await readGrid(page)
if (current.cols === previous.cols && current.rows === previous.rows) {
return current
}
previous = current
}
return previous
}
/** Locates each payload's viewport row and reads the data model three ways. */
export async function readBuffer(page: Page, cases: PayloadCase[]): Promise<BufferReading[]> {
return page.evaluate((items: PayloadCase[]) => {
const pane = window.__issue12164FindPane()
const terminal = pane.terminal
const buffer = terminal.buffer.active
const readings: BufferReading[] = []
for (const item of items) {
// Match on the first code point only — a doubled line (`프프로로…`) must
// still be found, so no two-character sequence of the original is safe.
const probe = Array.from(item.text)[0] ?? ''
let row: number | null = null
for (let candidate = terminal.rows - 1; candidate >= 0; candidate -= 1) {
const text = buffer.getLine(buffer.viewportY + candidate)?.translateToString(true) ?? ''
if (text.trimStart().startsWith(probe)) {
row = candidate
break
}
}
let translated = ''
let cells = ''
let selectionText = ''
if (row !== null) {
const line = buffer.getLine(buffer.viewportY + row)
translated = line?.translateToString(true) ?? ''
const parts: string[] = []
for (let column = 0; column < terminal.cols; column += 1) {
const cell = line?.getCell(column)
if (!cell) {
continue
}
const chars = cell.getChars()
if (chars === '' || chars === ' ') {
continue
}
parts.push(`${column}:${JSON.stringify(chars)}/w${cell.getWidth()}`)
}
cells = parts.join(' ')
terminal.clearSelection()
terminal.select(0, buffer.viewportY + row, terminal.cols)
selectionText = terminal.getSelection().replace(/\s+$/, '')
terminal.clearSelection()
}
readings.push({
id: item.id,
row,
translateToString: translated.replace(/\s+$/, ''),
selectionText,
cells,
cols: terminal.cols,
rows: terminal.rows
})
}
return readings
}, cases)
}
async function readRasterTarget(page: Page): Promise<RasterTarget> {
return page.evaluate(() => {
const pane = window.__issue12164FindPane()
const screen = pane.container.querySelector('.xterm-screen')
const cell = pane.terminal._core?._renderService?.dimensions?.css?.cell
if (!screen || !cell) {
throw new Error('terminal screen is not measurable')
}
const rect = screen.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) {
throw new Error('terminal screen is not visible')
}
const managers = [...(window.__paneManagers?.values() ?? [])] as unknown as {
getRenderingDiagnostics?: () => { paneId: number; hasWebgl?: boolean }[]
}[]
const diagnostics = managers
.flatMap((manager) => manager.getRenderingDiagnostics?.() ?? [])
.find((entry) => entry.paneId === pane.id)
return {
clip: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
cellWidth: cell.width,
cellHeight: cell.height,
cols: pane.terminal.cols,
rows: pane.terminal.rows,
renderer: diagnostics?.hasWebgl ? ('webgl' as const) : ('dom' as const)
}
})
}
function backgroundOf(
image: PNG,
band: { x0: number; y0: number; x1: number; y1: number }
): number {
const counts = new Map<number, number>()
for (let y = band.y0; y < band.y1; y += 1) {
for (let x = band.x0; x < band.x1; x += 1) {
const offset = (y * image.width + x) * 4
const key =
((image.data[offset] ?? 0) << 16) |
((image.data[offset + 1] ?? 0) << 8) |
(image.data[offset + 2] ?? 0)
counts.set(key, (counts.get(key) ?? 0) + 1)
}
}
let best = 0
let bestCount = -1
for (const [key, count] of counts) {
if (count > bestCount) {
best = key
bestCount = count
}
}
return best
}
const INK_DISTANCE = 36
/** Which grid columns carry painted ink on each payload row, straight off the canvas. */
export async function readCanvas(
page: Page,
rows: { id: string; row: number }[]
): Promise<{
readings: CanvasReading[]
renderer: 'webgl' | 'dom'
target: RasterTarget
}> {
const target = await readRasterTarget(page)
const shot = await page.screenshot({
clip: target.clip,
animations: 'disabled'
})
const image = PNG.sync.read(Buffer.from(shot))
const scaleX = image.width / target.clip.width
const scaleY = image.height / target.clip.height
const readings: CanvasReading[] = []
for (const { id, row } of rows) {
const y0 = Math.max(0, Math.round(row * target.cellHeight * scaleY))
const y1 = Math.min(image.height, Math.round((row + 1) * target.cellHeight * scaleY))
// Calibrate on the trailing quarter of the row, which no payload reaches.
const background = backgroundOf(image, {
x0: Math.round(image.width * 0.75),
y0,
x1: image.width,
y1
})
const bgR = (background >> 16) & 0xff
const bgG = (background >> 8) & 0xff
const bgB = background & 0xff
const inkedCells: number[] = []
// The pane's floating overlay control paints inside .xterm-screen on the
// top row, so the last two columns are not terminal ink. Any doubling of a
// 25-column payload would land near column 50, far inside this window.
const maxColumn = Math.max(0, target.cols - 2)
for (let column = 0; column < maxColumn; column += 1) {
const x0 = Math.round(column * target.cellWidth * scaleX)
const x1 = Math.min(image.width, Math.round((column + 1) * target.cellWidth * scaleX))
let inked = false
for (let y = y0; y < y1 && !inked; y += 1) {
for (let x = x0; x < x1; x += 1) {
const offset = (y * image.width + x) * 4
const distance =
Math.abs((image.data[offset] ?? 0) - bgR) +
Math.abs((image.data[offset + 1] ?? 0) - bgG) +
Math.abs((image.data[offset + 2] ?? 0) - bgB)
if (distance > INK_DISTANCE) {
inked = true
break
}
}
}
if (inked) {
inkedCells.push(column)
}
}
readings.push({
id,
row,
inkExtentColumns: inkedCells.length ? (inkedCells.at(-1) as number) + 1 : 0,
inkGroups: groupRuns(inkedCells)
})
}
return { readings, renderer: target.renderer, target }
}
function groupRuns(columns: number[]): string[] {
const groups: string[] = []
let start: number | null = null
let previous: number | null = null
for (const column of columns) {
if (start === null) {
start = column
} else if (previous !== null && column !== previous + 1) {
groups.push(`${start}-${previous}`)
start = column
}
previous = column
}
if (start !== null && previous !== null) {
groups.push(`${start}-${previous}`)
}
return groups
}
@@ -0,0 +1,120 @@
import path from 'node:path'
import type { Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import {
execInTerminal,
getTerminalContent,
waitForActivePanePtyId,
waitForActiveTerminalManager,
waitForTerminalOutput
} from './helpers/terminal'
import { nodeTerminalCommand } from './terminal-node-command'
import {
forceDomRenderer,
installPaneLookup,
readBuffer,
readCanvas,
readGrid,
waitForStableGrid,
type PayloadCase
} from './issue-12164-korean-glyph-doubling-probe'
// Issue #12164: `프로젝트 브랜딩 이름 확정` reported as `프프로로젝젝트트 …`.
// The payload is agent output, so no IME and no keystrokes are involved — the
// question is only whether the doubling lives in the data model or the paint.
const KOREAN = '프로젝트 브랜딩 이름 확정'
const CASES: PayloadCase[] = [
// 11 wide glyphs (2 columns each) + 3 spaces.
{ id: 'korean', text: KOREAN, expectedColumns: 25 },
{ id: 'ascii', text: 'project branding name', expectedColumns: 21 },
{ id: 'chinese', text: '项目品牌名称确定', expectedColumns: 16 },
// Multi-byte UTF-8 but narrow: separates "multi-byte" from "double-width".
{ id: 'cyrillic', text: 'проект бренд имя', expectedColumns: 16 }
]
const DONE_MARKER = 'ISSUE_12164_EMIT_DONE'
const EMIT_FIXTURE_PATH = path.join(process.cwd(), 'tests/e2e/fixtures/issue-12164-korean-emit.cjs')
function inspectionReport(label: string, value: unknown): string {
return `${label}: ${JSON.stringify(value, null, 2)}`
}
test.describe('issue #12164 Korean glyph doubling', () => {
for (const renderer of ['webgl', 'dom'] as const) {
test(`emitted Korean reads back identical from the buffer, the selection and the ${renderer} canvas`, async ({
orcaPage
}) => {
await runProbe(orcaPage, renderer)
})
}
})
async function runProbe(orcaPage: Page, renderer: 'webgl' | 'dom'): Promise<void> {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const ptyId = await waitForActivePanePtyId(orcaPage)
await installPaneLookup(orcaPage)
if (renderer === 'dom') {
await forceDomRenderer(orcaPage)
}
// Disabling GPU re-fits the pane; read the grid only once it has settled, or a
// stale emit-time reading turns a benign resize into a false doubling call.
const gridAtEmit = await waitForStableGrid(orcaPage)
// A real child process writing to the real PTY — exactly the shape of the
// agent output in the report, and with no IME anywhere in the path.
await execInTerminal(orcaPage, ptyId, nodeTerminalCommand([EMIT_FIXTURE_PATH]))
await waitForTerminalOutput(orcaPage, DONE_MARKER, 30_000)
await orcaPage.waitForTimeout(750)
const gridAtRead = await readGrid(orcaPage)
const bufferReadings = await readBuffer(orcaPage, CASES)
const locatedRows = bufferReadings
.filter((reading) => reading.row !== null)
.map((reading) => ({ id: reading.id, row: reading.row as number }))
const canvas = await readCanvas(orcaPage, locatedRows)
const evidence = [
inspectionReport('terminalContent', await getTerminalContent(orcaPage, 2000)),
inspectionReport('grid', { emit: gridAtEmit, read: gridAtRead }),
inspectionReport('rendererRequested', renderer),
inspectionReport('rendererActual', canvas.renderer),
inspectionReport('buffer', bufferReadings),
inspectionReport('canvas', canvas.readings)
].join('\n')
test.info().annotations.push({
type: 'issue-12164-evidence',
description: evidence
})
// eslint-disable-next-line no-console
console.log(`\n===== ISSUE 12164 EVIDENCE =====\n${evidence}\n===== END =====\n`)
// The grid must not have resized between emit and read, or a wrap artefact
// would masquerade as duplication.
expect(gridAtRead).toEqual(gridAtEmit)
for (const item of CASES) {
const reading = bufferReadings.find((entry) => entry.id === item.id)
expect(reading, `no buffer reading for ${item.id}`).toBeTruthy()
expect(reading?.row, `payload ${item.id} was not found in the viewport`).not.toBeNull()
// Boundary 1: the data model.
expect(reading?.translateToString, `buffer text for ${item.id}`).toBe(item.text)
// Boundary 1 again, via the copy path — xterm resolves getSelection()
// through the same buffer, so this cannot disagree with the line above.
expect(reading?.selectionText, `selection text for ${item.id}`).toBe(item.text)
// Boundary 2: the painted canvas. Doubling would roughly double the ink.
const painted = canvas.readings.find((entry) => entry.id === item.id)
expect(painted, `no canvas reading for ${item.id}`).toBeTruthy()
expect(painted?.inkExtentColumns, `canvas ink extent for ${item.id}`).toBeLessThanOrEqual(
item.expectedColumns
)
expect(painted?.inkExtentColumns, `canvas ink extent for ${item.id}`).toBeGreaterThan(
item.expectedColumns - 4
)
}
// The DOM arm is only meaningful if the pane actually left WebGL.
expect(canvas.renderer, 'requested renderer was not the active renderer').toBe(renderer)
}
+80
View File
@@ -0,0 +1,80 @@
import { execFileSync } from 'node:child_process'
/**
* Resolves a macOS input source from a list of candidate bundle IDs.
*
* Why candidates rather than one constant: Apple ships the same input method under different
* bundle IDs across macOS versions and localisations — Cangjie exists as BOTH
* `com.apple.inputmethod.TCIM.Cangjie` and `com.apple.inputmethod.TYIM.Cangjie` on a single
* host, and Simple Telex moved under a `VietnameseIM.` prefix. Hardcoding one ID makes a spec
* fail on hosts where the other is installed, and the failure reads like an operator error
* ("you didn't select the right source") rather than a stale constant.
*/
const LIST_INSTALLED_SWIFT = `
import Foundation
import Carbon
if let list = TISCreateInputSourceList(nil, true)?.takeRetainedValue() as? [TISInputSource] {
for s in list {
guard let idPtr = TISGetInputSourceProperty(s, kTISPropertyInputSourceID) else { continue }
let id = Unmanaged<CFString>.fromOpaque(idPtr).takeUnretainedValue() as String
var selectable = false
if let p = TISGetInputSourceProperty(s, kTISPropertyInputSourceIsSelectCapable) {
selectable = CFBooleanGetValue(Unmanaged<CFBoolean>.fromOpaque(p).takeUnretainedValue())
}
var enabled = false
if let p = TISGetInputSourceProperty(s, kTISPropertyInputSourceIsEnabled) {
enabled = CFBooleanGetValue(Unmanaged<CFBoolean>.fromOpaque(p).takeUnretainedValue())
}
print("\\(id)|\\(enabled)|\\(selectable)")
}
}
`
export type InstalledInputSource = { id: string; enabled: boolean; selectable: boolean }
export function listInstalledInputSources(): InstalledInputSource[] {
const output = execFileSync('swift', ['-'], { input: LIST_INSTALLED_SWIFT, encoding: 'utf8' })
return output
.split('\n')
.filter((line) => line.includes('|'))
.map((line) => {
const [id, enabled, selectable] = line.split('|')
return { id, enabled: enabled === 'true', selectable: selectable === 'true' }
})
}
/**
* Returns the first candidate that is installed AND selectable, or throws naming every candidate
* and what TIS actually reported. A spec that cannot establish its own input-source precondition
* must abort — proceeding captures the wrong source and the run looks valid.
*/
export function resolveInputSourceId(label: string, candidates: readonly string[]): string {
const installed = listInstalledInputSources()
const byId = new Map(installed.map((source) => [source.id, source]))
for (const candidate of candidates) {
if (byId.get(candidate)?.selectable) {
return candidate
}
}
const present = candidates
.map((candidate) => {
const found = byId.get(candidate)
return found
? ` ${candidate} — installed but selectable=${found.selectable}, enabled=${found.enabled}`
: ` ${candidate} — NOT INSTALLED`
})
.join('\n')
// Surface near-matches so a renamed bundle ID is obvious rather than looking like a missing IME.
const stem = label.toLowerCase()
const near = installed
.filter((source) => source.id.toLowerCase().includes(stem))
.map((source) => ` ${source.id} (enabled=${source.enabled}, selectable=${source.selectable})`)
const hint =
near.length > 0
? `TIS reports these ${label}-like sources instead:\n${near.join('\n')}`
: `TIS reports no ${label}-like source at all — enable it in System Settings > Keyboard.`
throw new Error(`No selectable input source for ${label}. Candidates:\n${present}\n${hint}`)
}
+52 -3
View File
@@ -13,11 +13,30 @@ export type TerminalImeDomEvent = {
selectionEnd: number | null
selectionStart: number | null
value: string
altGraph?: boolean | null
altKey?: boolean | null
charCode?: number | null
composed?: boolean
ctrlKey?: boolean | null
defaultPrevented?: boolean
location?: number | null
metaKey?: boolean | null
repeat?: boolean | null
shiftKey?: boolean | null
timeStamp?: number
target?: { className: string; tagName: string } | null
which?: number | null
}
export type TerminalImeDataEvent = {
data: string
timeStamp: number
}
export type TerminalImeBoundaryTrace = {
dom: TerminalImeDomEvent[]
onData: string[]
onDataEvents?: TerminalImeDataEvent[]
}
type TerminalImeProbeWindow = Window & {
@@ -46,6 +65,7 @@ export async function installTerminalImeBoundaryProbe(page: Page): Promise<void>
const dom: TerminalImeDomEvent[] = []
const onData: string[] = []
const onDataEvents: TerminalImeDataEvent[] = []
const record = (event: Event): void => {
const input = event instanceof InputEvent ? event : null
const composition = event instanceof CompositionEvent ? event : null
@@ -60,7 +80,23 @@ export async function installTerminalImeBoundaryProbe(page: Page): Promise<void>
isComposing: keyboard?.isComposing ?? input?.isComposing ?? null,
selectionEnd: textarea.selectionEnd,
selectionStart: textarea.selectionStart,
value: textarea.value
value: textarea.value,
altGraph: keyboard?.getModifierState('AltGraph') ?? null,
altKey: keyboard?.altKey ?? null,
charCode: keyboard?.charCode ?? null,
composed: event.composed,
ctrlKey: keyboard?.ctrlKey ?? null,
defaultPrevented: event.defaultPrevented,
location: keyboard?.location ?? null,
metaKey: keyboard?.metaKey ?? null,
repeat: keyboard?.repeat ?? null,
shiftKey: keyboard?.shiftKey ?? null,
timeStamp: event.timeStamp,
target:
event.target instanceof Element
? { className: event.target.className, tagName: event.target.tagName }
: null,
which: keyboard?.which ?? null
})
}
const eventTypes = [
@@ -76,10 +112,14 @@ export async function installTerminalImeBoundaryProbe(page: Page): Promise<void>
for (const eventType of eventTypes) {
textarea.addEventListener(eventType, record, true)
}
const onDataDisposable = pane.terminal.onData((data) => onData.push(data))
const onDataDisposable = pane.terminal.onData((data) => {
onData.push(data)
onDataEvents.push({ data, timeStamp: performance.now() })
})
targetWindow.__terminalImeBoundaryProbe = {
dom,
onData,
onDataEvents,
dispose: () => {
for (const eventType of eventTypes) {
textarea.removeEventListener(eventType, record, true)
@@ -93,7 +133,16 @@ export async function installTerminalImeBoundaryProbe(page: Page): Promise<void>
export async function readTerminalImeBoundaryTrace(page: Page): Promise<TerminalImeBoundaryTrace> {
return page.evaluate(() => {
const probe = (window as TerminalImeProbeWindow).__terminalImeBoundaryProbe
return probe ? { dom: [...probe.dom], onData: [...probe.onData] } : { dom: [], onData: [] }
// An uninstalled probe returning an empty trace makes every "nothing leaked" negative pass
// vacuously, so absence must throw rather than read as silence.
if (!probe) {
throw new Error('terminal IME boundary probe was never installed')
}
return {
dom: [...probe.dom],
onData: [...probe.onData],
onDataEvents: [...(probe.onDataEvents ?? [])]
}
})
}
@@ -135,7 +135,7 @@ async function runNativeScenario(
const expectedOnData = ordinaryControl
? `${expectedText}\rordinary\r`
: preCommit?.shiftEnter
? `${expectedText}\x1b\rordinary`
? `${expectedText}\x1b\rordinary\x1b\r`
: `${expectedText}\r`
expect(trace.onData.join('')).toBe(expectedOnData)
completed = true
@@ -0,0 +1,111 @@
import { execFileSync } from 'node:child_process'
import type { Page } from '@stablyai/playwright-test'
import { expect, test } from './helpers/orca-app'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import {
focusActiveTerminalInput,
waitForActivePanePtyId,
waitForActiveTerminalManager
} from './helpers/terminal'
import {
attachTerminalImeBoundaryEvidence,
disposeTerminalImeBoundaryProbe,
installTerminalImeBoundaryProbe,
readTerminalImeBoundaryTrace
} from './terminal-ime-boundary-probe'
import {
createTerminalImeByteReader,
removeTerminalImeByteReader,
startTerminalImeByteReader,
waitForTerminalImeBytes
} from './terminal-ime-byte-reader'
const ABC_ID = 'com.apple.keylayout.ABC'
const KOREAN_ID = 'com.apple.inputmethod.Korean.2SetKorean'
function typePhysicalKeys(processId: number, keyCodes: readonly number[]): void {
execFileSync('osascript', [
'-e',
`tell application "System Events" to set frontmost of first application process whose unix id is ${processId} to true`,
'-e',
'tell application "System Events"',
'-e',
`repeat with currentKeyCode in {${keyCodes.join(', ')}}`,
'-e',
'key code (currentKeyCode as integer)',
'-e',
'delay 0.1',
'-e',
'end repeat',
'-e',
'end tell'
])
}
async function waitForInputSource(page: Page, expected: string): Promise<void> {
await expect
.poll(() => page.evaluate(() => window.api.app.getKeyboardInputSourceId()))
.toBe(expected)
}
test.describe('Native macOS automatic period substitution @headful', () => {
test.skip(
process.platform !== 'darwin' || process.env.ORCA_E2E_NATIVE_MACOS_PERIOD !== '1',
'Requires explicit native macOS period-substitution evidence mode'
)
test('records Hangul word-boundary and ABC punctuation streams', async ({
electronApp,
orcaPage,
testRepoPath
}, testInfo) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const ptyId = await waitForActivePanePtyId(orcaPage)
const hangulReader = createTerminalImeByteReader(testRepoPath, 1)
const abcReader = createTerminalImeByteReader(testRepoPath, 1)
try {
await startTerminalImeByteReader(orcaPage, ptyId, hangulReader)
await focusActiveTerminalInput(orcaPage)
await installTerminalImeBoundaryProbe(orcaPage)
execFileSync('swift', ['.tmp/select-input-source.swift', KOREAN_ID])
await waitForInputSource(orcaPage, KOREAN_ID)
typePhysicalKeys(electronApp.process().pid!, [2, 40, 49])
await orcaPage.waitForTimeout(500)
const hangulBeforeEnter = await readTerminalImeBoundaryTrace(orcaPage)
expect(hangulBeforeEnter.onData.join('')).toBe('아 ')
typePhysicalKeys(electronApp.process().pid!, [36])
const hangulPty = await waitForTerminalImeBytes(orcaPage, hangulReader)
await startTerminalImeByteReader(orcaPage, ptyId, abcReader)
await focusActiveTerminalInput(orcaPage)
execFileSync('swift', ['.tmp/select-input-source.swift', ABC_ID])
await waitForInputSource(orcaPage, ABC_ID)
typePhysicalKeys(electronApp.process().pid!, [47, 49, 36])
const abcPty = await waitForTerminalImeBytes(orcaPage, abcReader)
expect([hangulPty[0], abcPty[0]]).toEqual([
Buffer.from('아 \n').toString('hex'),
Buffer.from('. \n').toString('hex')
])
const trace = await readTerminalImeBoundaryTrace(orcaPage)
expect(trace.onData.join('')).toBe('아 \r. \r')
await attachTerminalImeBoundaryEvidence(
orcaPage,
testInfo,
'native-macos-automatic-period-boundaries',
{ abcPty, automaticPeriodPreference: true, hangulBeforeEnter, hangulPty }
)
} finally {
execFileSync('swift', ['.tmp/select-input-source.swift', ABC_ID])
await disposeTerminalImeBoundaryProbe(orcaPage).catch(() => undefined)
removeTerminalImeByteReader(hangulReader)
removeTerminalImeByteReader(abcReader)
}
})
})
@@ -14,6 +14,7 @@ import {
installTerminalImeBoundaryProbe,
readTerminalImeBoundaryTrace
} from './terminal-ime-boundary-probe'
import { resolveInputSourceId } from './macos-input-source-resolver'
import {
createTerminalImeByteReader,
removeTerminalImeByteReader,
@@ -22,7 +23,15 @@ import {
} from './terminal-ime-byte-reader'
const ABC_ID = 'com.apple.keylayout.ABC'
const CANGJIE_ID = 'com.apple.inputmethod.TCIM.Cangjie'
// Apple ships Cangjie under both bundle IDs — this host has TCIM.Cangjie and TYIM.Cangjie
// installed simultaneously. Resolved lazily so collection on non-darwin never shells out to swift.
const CANGJIE_CANDIDATES = [
'com.apple.inputmethod.TCIM.Cangjie',
'com.apple.inputmethod.TYIM.Cangjie'
] as const
let resolvedCangjieId: string | null = null
const cangjieInputSourceId = (): string =>
(resolvedCangjieId ??= resolveInputSourceId('cangjie', CANGJIE_CANDIDATES))
const RECORDED_CANGJIE_CANCEL_BOUNDARIES = [
{ type: 'keydown', key: '尸', code: 'KeyS', keyCode: 229, isComposing: false },
{ type: 'compositionstart', data: '' },
@@ -89,10 +98,14 @@ test.describe('Native macOS Cangjie terminal input @headful', () => {
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
await expect(orcaPage.evaluate(() => window.api.app.getKeyboardInputSourceId())).resolves.toBe(
CANGJIE_ID
cangjieInputSourceId()
)
const ptyId = await waitForActivePanePtyId(orcaPage)
// The line count MUST stay 1 — see the identical note in terminal-macos-pinyin-native.spec.ts.
// The cancelled preedit emits nothing, so "nothing leaked" is enforced at the PTY by the single
// captured line being exactly `ordinary\n`: a leaked key carries no newline and would prepend to
// it. Raising this count silently turns that into a check that only the control worked.
const reader = createTerminalImeByteReader(testRepoPath, 1)
let completed = false
try {
@@ -92,6 +92,12 @@ test.describe('Native macOS Pinyin terminal input @headful', () => {
)
const ptyId = await waitForActivePanePtyId(orcaPage)
// The line count MUST stay 1. The cancel arm emits nothing, so it cannot be asserted at the PTY
// directly; instead the single captured line is the FIRST thing to reach the PTY child, and a
// leaked cancel key (which carries no newline) would prepend to it — `cordinary\n` rather than
// `ordinary\n`. That prefix property is what enforces "nothing leaked" at the byte boundary.
// Raising this count, or sending anything newline-terminated before the control, silently
// downgrades the assertion to "the control worked" with no test failure to signal it.
const reader = createTerminalImeByteReader(testRepoPath, 1)
let completed = false
try {
@@ -13,6 +13,7 @@ import {
installTerminalImeBoundaryProbe,
readTerminalImeBoundaryTrace
} from './terminal-ime-boundary-probe'
import { resolveInputSourceId } from './macos-input-source-resolver'
import {
createTerminalImeByteReader,
removeTerminalImeByteReader,
@@ -21,7 +22,16 @@ import {
} from './terminal-ime-byte-reader'
const ABC_ID = 'com.apple.keylayout.ABC'
const SIMPLE_TELEX_ID = 'com.apple.inputmethod.VietnameseSimpleTelex'
// The bare `VietnameseSimpleTelex` ID is NOT installed on current macOS — the real one is nested
// under `VietnameseIM.`. Verified by live TIS enumeration 2026-08-05. Both are kept as candidates
// because the flat form shipped on older releases. Resolved lazily; see the Cangjie spec.
const SIMPLE_TELEX_CANDIDATES = [
'com.apple.inputmethod.VietnameseIM.VietnameseSimpleTelex',
'com.apple.inputmethod.VietnameseSimpleTelex'
] as const
let resolvedSimpleTelexId: string | null = null
const simpleTelexInputSourceId = (): string =>
(resolvedSimpleTelexId ??= resolveInputSourceId('vietnamese', SIMPLE_TELEX_CANDIDATES))
const RECORDED_VIETNAMESE_COMMIT_BOUNDARIES = [
{ type: 'compositionend', data: 'tiếng ', value: 'tiếng ' },
{ type: 'compositionend', data: 'việt', value: 'tiếng việt' }
@@ -66,7 +76,7 @@ test.describe('Native macOS Vietnamese terminal input @headful', () => {
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
await expect(orcaPage.evaluate(() => window.api.app.getKeyboardInputSourceId())).resolves.toBe(
SIMPLE_TELEX_ID
simpleTelexInputSourceId()
)
const ptyId = await waitForActivePanePtyId(orcaPage)