mirror of
https://github.com/stablyai/orca.git
synced 2026-09-24 00:02:24 +00:00
test(e2e): pin the macOS Korean preedit as visible only while composing
#11914 reports the composing text invisible until Space. Its c3 was recorded as unobtainable, and the reason on file was wrong: the boundary IS assertable, but not in happy-dom, which reports display:block in BOTH the active and inactive states and zeros for every rect. A test there passes with the defect present. Captured on real hardware instead: hidden and 0x0 before, .active with display:block, a 15.84x16 rect and checkVisibility() true while composing 그, hidden again after. 39 DOM events, 2 composition starts, onData ["한","그","\r"]. Two mechanism findings are carried in the setup because both are invisible in the result and fatal if removed. The input source must be selected AFTER the app takes focus — focusing resets it to ABC. And the IME must be warmed until an observed keyCode 229; typed cold it emits raw QWERTY (g k s r m) with no composition at all, which is indistinguishable from an IME that is not installed. Two runs were voided on exactly that signature before the warm-up was found. The has229 and compositionStarts assertions exist to make such a run fail loudly rather than pass as a clean negative. Gated on darwin plus ORCA_E2E_NATIVE_MACOS_KOREAN, like its siblings. The final spec form has not itself been executed — the machine became unavailable — so it carries the probe's measured values as literals rather than a run of its own.
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Pins the preedit overlay's *visibility* — not just its intent — during native macOS 2-Set
|
||||
* Korean composition.
|
||||
*
|
||||
* Why a live browser: happy-dom resolves the overlay's intent (the `active` class, its
|
||||
* textContent) but reports `display: block` in both states with all-zero rects, so a DOM-only
|
||||
* arm passes over an overlay that never renders. Only a real Electron window can tell the two
|
||||
* apart.
|
||||
*
|
||||
* `during.rect` is the single load-bearing assertion here. Measured against an overlay forced
|
||||
* to `max-width: 0; overflow: hidden` — invisible on screen — the `active` class, the
|
||||
* textContent, `display: block` AND `checkVisibility() === true` all still pass. The bounding
|
||||
* rect is the only signal that fails. Do not weaken it to a visibility or class check.
|
||||
*
|
||||
* Two macOS mechanisms make a Korean-looking precondition record raw QWERTY, and neither is
|
||||
* visible in the input-source pref. Both are handled in `establishLiveKoreanIme`:
|
||||
*
|
||||
* 1. The system input source reverts to the previous keyboard on its own ~7-8s after being
|
||||
* set, so any source selected before launch is a race, and a pre-flight assertion
|
||||
* certifies a source that will not be in force when the keys land. Select late, and
|
||||
* assert *after* the IME is live rather than before.
|
||||
* 2. An app adopts a new input source when it *activates*, not when the pref changes. A
|
||||
* source selected while the app is already frontmost never reaches its input context, so
|
||||
* the selection must be followed by a focus bounce.
|
||||
*
|
||||
* A run that skips either emits the ASCII letters for the key codes below rather than Hangul,
|
||||
* with a correct-looking Korean id in the pref — the shape recorded in HAZARD-REGISTER.md E1.
|
||||
* The `has229` / `compositionstart` assertions exist to fail that run loudly.
|
||||
*
|
||||
* Values below were recorded from a live run under `com.apple.inputmethod.Korean.2SetKorean`;
|
||||
* see `11914-c3-live-preedit-visibility.txt` in the lane capture directory. That capture is
|
||||
* gitignored and is cited, never imported.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import path from 'node:path'
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
focusActiveTerminalInput,
|
||||
sendToTerminal,
|
||||
waitForActivePanePtyId,
|
||||
waitForActiveTerminalManager
|
||||
} from './helpers/terminal'
|
||||
import {
|
||||
disposeTerminalImeBoundaryProbe,
|
||||
installTerminalImeBoundaryProbe,
|
||||
readTerminalImeBoundaryTrace
|
||||
} from './terminal-ime-boundary-probe'
|
||||
|
||||
const TWO_SET_KOREAN_ID = 'com.apple.inputmethod.Korean.2SetKorean'
|
||||
/** ㅎㅏㄴ ㄱㅡ — commits 한 and leaves 그 in preedit. */
|
||||
const HAN_GEU_KEY_CODES = [5, 40, 1, 15, 46] as const
|
||||
const WARM_UP_KEY_CODES = [5, 40, 1] as const
|
||||
const ESCAPE_KEY_CODE = 53
|
||||
const ENTER_KEY_CODE = 36
|
||||
const WARM_UP_ATTEMPT_LIMIT = 6
|
||||
|
||||
/** Recorded live. Widths are font-metric dependent, so the assertions below pin the
|
||||
* discriminating properties rather than these exact floats. */
|
||||
const RECORDED_SAMPLE = {
|
||||
baseline: { display: 'none', width: 0, height: 0, checkVisibility: false },
|
||||
during: { display: 'block', width: 15.84375, height: 16, checkVisibility: true, text: '그' },
|
||||
after: { display: 'none', width: 0, height: 0, checkVisibility: false },
|
||||
onData: ['한', '그', '\r']
|
||||
} as const
|
||||
|
||||
type PreeditSample = {
|
||||
found: boolean
|
||||
hasActiveClass: boolean
|
||||
textContent: string
|
||||
rect: { width: number; height: number }
|
||||
checkVisibility: boolean | null
|
||||
display: string
|
||||
visibility: string
|
||||
maxWidth: string
|
||||
overflow: string
|
||||
}
|
||||
|
||||
function pressKeyCodes(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.12',
|
||||
'-e',
|
||||
'end repeat',
|
||||
'-e',
|
||||
'end tell'
|
||||
])
|
||||
}
|
||||
|
||||
function samplePreedit(page: Page): Promise<PreeditSample> {
|
||||
return page.evaluate(() => {
|
||||
const textarea = document.querySelector<HTMLTextAreaElement>('.xterm-helper-textarea:focus')
|
||||
const view = textarea?.parentElement?.querySelector<HTMLElement>('.composition-view') ?? null
|
||||
if (!view) {
|
||||
return {
|
||||
found: false,
|
||||
hasActiveClass: false,
|
||||
textContent: '',
|
||||
rect: { width: 0, height: 0 },
|
||||
checkVisibility: null,
|
||||
display: '',
|
||||
visibility: '',
|
||||
maxWidth: '',
|
||||
overflow: ''
|
||||
}
|
||||
}
|
||||
const style = getComputedStyle(view)
|
||||
const rect = view.getBoundingClientRect()
|
||||
return {
|
||||
found: true,
|
||||
hasActiveClass: view.classList.contains('active'),
|
||||
textContent: (view.textContent ?? '').replaceAll('', ''),
|
||||
rect: { width: rect.width, height: rect.height },
|
||||
checkVisibility: typeof view.checkVisibility === 'function' ? view.checkVisibility() : null,
|
||||
display: style.display,
|
||||
visibility: style.visibility,
|
||||
maxWidth: style.maxWidth,
|
||||
overflow: style.overflow
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function selectKoreanInputSource(): void {
|
||||
execFileSync('swift', [
|
||||
path.join(process.cwd(), 'tests', 'e2e', 'select-input-source.swift'),
|
||||
TWO_SET_KOREAN_ID
|
||||
])
|
||||
}
|
||||
|
||||
/** Why: an app adopts a new input source when it *activates*, not when the pref changes, so a
|
||||
* source selected while the app is already frontmost never reaches its input context. Bounce
|
||||
* focus away and back to force the re-sync. */
|
||||
function reactivateApp(processId: number): void {
|
||||
execFileSync('osascript', [
|
||||
'-e',
|
||||
'tell application "Finder" to activate',
|
||||
'-e',
|
||||
'delay 0.2',
|
||||
'-e',
|
||||
`tell application "System Events" to set frontmost of first application process whose unix id is ${processId} to true`,
|
||||
'-e',
|
||||
'delay 0.2'
|
||||
])
|
||||
}
|
||||
|
||||
/** Why: two independent failures make a Korean-looking precondition record ASCII — the system
|
||||
* source reverts on its own within seconds of being set, and a cold IME delivers its first
|
||||
* jamo as non-composing insertText. Neither is visible in the pref, so establish the IME at
|
||||
* the boundary instead: re-select, re-activate, and warm until a real keyCode 229 lands. A
|
||||
* lone jamo never wakes it; the unit that does is a multi-key sequence. */
|
||||
async function establishLiveKoreanIme(page: Page, processId: number): Promise<boolean> {
|
||||
await installTerminalImeBoundaryProbe(page)
|
||||
try {
|
||||
for (let attempt = 0; attempt < WARM_UP_ATTEMPT_LIMIT; attempt += 1) {
|
||||
selectKoreanInputSource()
|
||||
reactivateApp(processId)
|
||||
pressKeyCodes(processId, WARM_UP_KEY_CODES)
|
||||
pressKeyCodes(processId, [ESCAPE_KEY_CODE])
|
||||
const trace = await readTerminalImeBoundaryTrace(page)
|
||||
if (trace.dom.some((event) => event.keyCode === 229)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
await disposeTerminalImeBoundaryProbe(page)
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Native macOS 2-Set Korean preedit visibility @headful', () => {
|
||||
test.skip(
|
||||
process.platform !== 'darwin' || process.env.ORCA_E2E_NATIVE_MACOS_KOREAN !== '1',
|
||||
'Requires macOS with 2-Set Korean selected and Accessibility access'
|
||||
)
|
||||
|
||||
test('renders the composing overlay and hides it either side', async ({
|
||||
electronApp,
|
||||
orcaPage
|
||||
}) => {
|
||||
const page = orcaPage
|
||||
const processId = electronApp.process().pid!
|
||||
await waitForSessionReady(page)
|
||||
await waitForActiveWorktree(page)
|
||||
await ensureTerminalVisible(page)
|
||||
await waitForActiveTerminalManager(page, 30_000)
|
||||
|
||||
const ptyId = await waitForActivePanePtyId(page)
|
||||
await focusActiveTerminalInput(page)
|
||||
expect(await establishLiveKoreanIme(page, processId)).toBe(true)
|
||||
// Why: asserted after the IME is live, not before — the system source reverts on its own
|
||||
// within seconds, so a pre-flight read says nothing about the source that will be in force.
|
||||
await expect(page.evaluate(() => window.api.app.getKeyboardInputSourceId())).resolves.toBe(
|
||||
TWO_SET_KOREAN_ID
|
||||
)
|
||||
|
||||
await installTerminalImeBoundaryProbe(page)
|
||||
let committed = false
|
||||
try {
|
||||
const baseline = await samplePreedit(page)
|
||||
expect(baseline.found).toBe(true)
|
||||
expect(baseline.hasActiveClass).toBe(false)
|
||||
expect(baseline.display).toBe(RECORDED_SAMPLE.baseline.display)
|
||||
expect(baseline.checkVisibility).toBe(RECORDED_SAMPLE.baseline.checkVisibility)
|
||||
expect(baseline.rect.width).toBe(RECORDED_SAMPLE.baseline.width)
|
||||
expect(baseline.rect.height).toBe(RECORDED_SAMPLE.baseline.height)
|
||||
|
||||
pressKeyCodes(processId, HAN_GEU_KEY_CODES)
|
||||
await expect.poll(async () => (await samplePreedit(page)).hasActiveClass).toBe(true)
|
||||
const during = await samplePreedit(page)
|
||||
|
||||
// Why: the precondition. Without an engaged IME the overlay assertions below are
|
||||
// vacuous — an ASCII passthrough leaves the overlay hidden for the honest reason.
|
||||
const composingTrace = await readTerminalImeBoundaryTrace(page)
|
||||
expect(composingTrace.dom.some((event) => event.keyCode === 229)).toBe(true)
|
||||
expect(
|
||||
composingTrace.dom.filter((event) => event.type === 'compositionstart').length
|
||||
).toBeGreaterThan(0)
|
||||
|
||||
expect(during.textContent).toBe(RECORDED_SAMPLE.during.text)
|
||||
expect(during.display).toBe(RECORDED_SAMPLE.during.display)
|
||||
expect(during.checkVisibility).toBe(RECORDED_SAMPLE.during.checkVisibility)
|
||||
expect(during.rect.width).toBeGreaterThan(0)
|
||||
expect(during.rect.height).toBeGreaterThan(0)
|
||||
expect(during.visibility).toBe('visible')
|
||||
// Why: an overlay laid out at maxWidth 0 with overflow hidden is invisible on screen and
|
||||
// indistinguishable from a rendered one to every non-geometric assertion.
|
||||
expect(during.maxWidth).not.toBe('0px')
|
||||
|
||||
pressKeyCodes(processId, [ENTER_KEY_CODE])
|
||||
committed = true
|
||||
await expect.poll(async () => (await samplePreedit(page)).hasActiveClass).toBe(false)
|
||||
const after = await samplePreedit(page)
|
||||
expect(after.display).toBe(RECORDED_SAMPLE.after.display)
|
||||
expect(after.checkVisibility).toBe(RECORDED_SAMPLE.after.checkVisibility)
|
||||
expect(after.rect.width).toBe(RECORDED_SAMPLE.after.width)
|
||||
expect(after.rect.height).toBe(RECORDED_SAMPLE.after.height)
|
||||
|
||||
expect((await readTerminalImeBoundaryTrace(page)).onData).toEqual([...RECORDED_SAMPLE.onData])
|
||||
} finally {
|
||||
await disposeTerminalImeBoundaryProbe(page).catch(() => undefined)
|
||||
if (!committed) {
|
||||
await sendToTerminal(page, ptyId, '\x03').catch(() => undefined)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user