From 48cf6accef92a41d2d2aa457a0bcc594f35bc602 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:53:56 -0700 Subject: [PATCH] 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 --- .../e2e/codex-composer-echo-latency-probe.ts | 13 + .../e2e/fixtures/issue-12164-korean-emit.cjs | 14 + ...issue-12164-korean-glyph-doubling-probe.ts | 347 ++++++++++++++++++ .../issue-12164-korean-glyph-doubling.spec.ts | 120 ++++++ tests/e2e/macos-input-source-resolver.ts | 80 ++++ tests/e2e/terminal-ime-boundary-probe.ts | 55 ++- .../terminal-macos-2set-korean-native.spec.ts | 2 +- ...inal-macos-automatic-period-native.spec.ts | 111 ++++++ .../e2e/terminal-macos-cangjie-native.spec.ts | 17 +- .../e2e/terminal-macos-pinyin-native.spec.ts | 6 + .../terminal-macos-vietnamese-native.spec.ts | 14 +- 11 files changed, 771 insertions(+), 8 deletions(-) create mode 100644 tests/e2e/fixtures/issue-12164-korean-emit.cjs create mode 100644 tests/e2e/issue-12164-korean-glyph-doubling-probe.ts create mode 100644 tests/e2e/issue-12164-korean-glyph-doubling.spec.ts create mode 100644 tests/e2e/macos-input-source-resolver.ts create mode 100644 tests/e2e/terminal-macos-automatic-period-native.spec.ts diff --git a/tests/e2e/codex-composer-echo-latency-probe.ts b/tests/e2e/codex-composer-echo-latency-probe.ts index 9fc9cabf2c7..dc4f2a05f7c 100644 --- a/tests/e2e/codex-composer-echo-latency-probe.ts +++ b/tests/e2e/codex-composer-echo-latency-probe.ts @@ -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, diff --git a/tests/e2e/fixtures/issue-12164-korean-emit.cjs b/tests/e2e/fixtures/issue-12164-korean-emit.cjs new file mode 100644 index 00000000000..72aef2884a5 --- /dev/null +++ b/tests/e2e/fixtures/issue-12164-korean-emit.cjs @@ -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') diff --git a/tests/e2e/issue-12164-korean-glyph-doubling-probe.ts b/tests/e2e/issue-12164-korean-glyph-doubling-probe.ts new file mode 100644 index 00000000000..2a69ab675d1 --- /dev/null +++ b/tests/e2e/issue-12164-korean-glyph-doubling-probe.ts @@ -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 { + 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 { + 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 { + // 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) => 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 { + 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 { + 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 { + 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() + 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 +} diff --git a/tests/e2e/issue-12164-korean-glyph-doubling.spec.ts b/tests/e2e/issue-12164-korean-glyph-doubling.spec.ts new file mode 100644 index 00000000000..7070bd09f1e --- /dev/null +++ b/tests/e2e/issue-12164-korean-glyph-doubling.spec.ts @@ -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 { + 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) +} diff --git a/tests/e2e/macos-input-source-resolver.ts b/tests/e2e/macos-input-source-resolver.ts new file mode 100644 index 00000000000..301630943c0 --- /dev/null +++ b/tests/e2e/macos-input-source-resolver.ts @@ -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.fromOpaque(idPtr).takeUnretainedValue() as String + var selectable = false + if let p = TISGetInputSourceProperty(s, kTISPropertyInputSourceIsSelectCapable) { + selectable = CFBooleanGetValue(Unmanaged.fromOpaque(p).takeUnretainedValue()) + } + var enabled = false + if let p = TISGetInputSourceProperty(s, kTISPropertyInputSourceIsEnabled) { + enabled = CFBooleanGetValue(Unmanaged.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}`) +} diff --git a/tests/e2e/terminal-ime-boundary-probe.ts b/tests/e2e/terminal-ime-boundary-probe.ts index f32d4ab9fa1..cc1bdcb3ce0 100644 --- a/tests/e2e/terminal-ime-boundary-probe.ts +++ b/tests/e2e/terminal-ime-boundary-probe.ts @@ -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 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 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 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 export async function readTerminalImeBoundaryTrace(page: Page): Promise { 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 ?? [])] + } }) } diff --git a/tests/e2e/terminal-macos-2set-korean-native.spec.ts b/tests/e2e/terminal-macos-2set-korean-native.spec.ts index 632bba34e31..2067909d6c4 100644 --- a/tests/e2e/terminal-macos-2set-korean-native.spec.ts +++ b/tests/e2e/terminal-macos-2set-korean-native.spec.ts @@ -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 diff --git a/tests/e2e/terminal-macos-automatic-period-native.spec.ts b/tests/e2e/terminal-macos-automatic-period-native.spec.ts new file mode 100644 index 00000000000..18b1d0d369f --- /dev/null +++ b/tests/e2e/terminal-macos-automatic-period-native.spec.ts @@ -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 { + 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) + } + }) +}) diff --git a/tests/e2e/terminal-macos-cangjie-native.spec.ts b/tests/e2e/terminal-macos-cangjie-native.spec.ts index 8e468208890..50b5533475e 100644 --- a/tests/e2e/terminal-macos-cangjie-native.spec.ts +++ b/tests/e2e/terminal-macos-cangjie-native.spec.ts @@ -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 { diff --git a/tests/e2e/terminal-macos-pinyin-native.spec.ts b/tests/e2e/terminal-macos-pinyin-native.spec.ts index 67a021e8751..e3514d81c3f 100644 --- a/tests/e2e/terminal-macos-pinyin-native.spec.ts +++ b/tests/e2e/terminal-macos-pinyin-native.spec.ts @@ -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 { diff --git a/tests/e2e/terminal-macos-vietnamese-native.spec.ts b/tests/e2e/terminal-macos-vietnamese-native.spec.ts index badf3d5ca43..6f3ad053123 100644 --- a/tests/e2e/terminal-macos-vietnamese-native.spec.ts +++ b/tests/e2e/terminal-macos-vietnamese-native.spec.ts @@ -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)