fix(terminal): reconcile cross-platform IME composition lifecycle (#11293)
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: JeongUk Park <jeongph.dev@gmail.com>
This commit is contained in:
parent
6c3b2cfb39
commit
fe6f929c6e
|
|
@ -0,0 +1,99 @@
|
|||
name: Terminal IME E2E
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/terminal-ime-e2e.yml'
|
||||
- 'config/patches/@xterm__xterm@6.1.0-beta.287.patch'
|
||||
- 'config/scripts/run-terminal-ibus-hangul-e2e.mjs'
|
||||
- 'config/scripts/terminal-ime-e2e-workflow.test.mjs'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'src/renderer/src/components/terminal-pane/keyboard-handlers.ts'
|
||||
- 'src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx'
|
||||
- 'src/renderer/src/components/terminal-pane/pty-connection.ts'
|
||||
- 'src/renderer/src/components/terminal-pane/pty-connection.test.ts'
|
||||
- 'src/renderer/src/components/terminal-pane/terminal-ime-*'
|
||||
- 'src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts'
|
||||
- 'src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts'
|
||||
- 'src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts'
|
||||
- 'tests/e2e/chinese-ime-chat-input-repro.spec.ts'
|
||||
- 'tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts'
|
||||
- 'tests/e2e/terminal-ibus-hangul-native.spec.ts'
|
||||
- 'tests/e2e/terminal-ime-*.ts'
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '30 9 * * *'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
linux-x11:
|
||||
name: Linux X11 terminal IME
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 25
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install native build and IME tools
|
||||
run: >-
|
||||
sudo apt-get update &&
|
||||
sudo apt-get install -y
|
||||
build-essential
|
||||
dbus-x11
|
||||
dconf-gsettings-backend
|
||||
ibus
|
||||
ibus-hangul
|
||||
libglib2.0-bin
|
||||
python3
|
||||
xdotool
|
||||
xfwm4
|
||||
xvfb
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Use external node-gyp to avoid pnpm bundled copy
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Electron app for E2E
|
||||
run: pnpm exec electron-vite build --mode e2e
|
||||
|
||||
- name: Run deterministic terminal IME boundary tests
|
||||
run: >-
|
||||
xvfb-run --auto-servernum
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1
|
||||
pnpm run test:e2e --
|
||||
tests/e2e/terminal-ime-exact-byte.spec.ts
|
||||
--workers=1
|
||||
|
||||
- name: Run native IBus Hangul exact-byte tests
|
||||
env:
|
||||
SKIP_BUILD: '1'
|
||||
run: pnpm run test:e2e:terminal-ime-native
|
||||
|
||||
- name: Upload terminal IME evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: terminal-ime-evidence
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,306 @@
|
|||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { closeSync, copyFileSync, mkdirSync, mkdtempSync, openSync, writeFileSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
const projectDir = path.resolve(import.meta.dirname, '../..')
|
||||
const scriptPath = import.meta.filename
|
||||
const insideSessionFlag = '--inside-session'
|
||||
const processStopTimeoutMs = 5_000
|
||||
const processKillTimeoutMs = 1_000
|
||||
|
||||
function delay(milliseconds) {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds))
|
||||
}
|
||||
|
||||
function waitForExit(child) {
|
||||
return new Promise((resolve, reject) => {
|
||||
child.once('error', reject)
|
||||
child.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)))
|
||||
})
|
||||
}
|
||||
|
||||
function processGroupMembers(processGroupId) {
|
||||
const result = spawnSync('ps', ['-o', 'pid=,ppid=,pgid=,comm=', '-g', String(processGroupId)], {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
return []
|
||||
}
|
||||
return result.stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
async function stopOwnedProcessGroup(processGroupId) {
|
||||
let members = processGroupMembers(processGroupId)
|
||||
if (members.length === 0) {
|
||||
return []
|
||||
}
|
||||
console.error(
|
||||
`[terminal-ime] stopping owned process group ${processGroupId}: ${members.join('; ')}`
|
||||
)
|
||||
try {
|
||||
process.kill(-processGroupId, 'SIGTERM')
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ESRCH') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const deadline = Date.now() + processStopTimeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
members = processGroupMembers(processGroupId)
|
||||
if (members.length === 0) {
|
||||
return []
|
||||
}
|
||||
await delay(100)
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-processGroupId, 'SIGKILL')
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ESRCH') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
const killDeadline = Date.now() + processKillTimeoutMs
|
||||
do {
|
||||
members = processGroupMembers(processGroupId)
|
||||
if (members.length === 0) {
|
||||
return []
|
||||
}
|
||||
await delay(100)
|
||||
} while (Date.now() < killDeadline)
|
||||
return members
|
||||
}
|
||||
|
||||
function commandOutput(command, args) {
|
||||
const result = spawnSync(command, args, { encoding: 'utf8' })
|
||||
return result.status === 0 ? result.stdout.trim() : result.stderr.trim()
|
||||
}
|
||||
|
||||
function configureHangulEngine() {
|
||||
for (const [key, value] of [
|
||||
['initial-input-mode', 'hangul'],
|
||||
['hangul-keyboard', '2']
|
||||
]) {
|
||||
const result = spawnSync(
|
||||
'gsettings',
|
||||
['set', 'org.freedesktop.ibus.engine.hangul', key, value],
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Failed to configure IBus Hangul ${key}: ${result.stderr.trim()}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForHangulEngine(ibusProcess) {
|
||||
const deadline = Date.now() + 15_000
|
||||
while (Date.now() < deadline) {
|
||||
if (ibusProcess.exitCode !== null) {
|
||||
throw new Error(`ibus-daemon exited early with code ${ibusProcess.exitCode}`)
|
||||
}
|
||||
const result = spawnSync('ibus', ['engine', 'hangul'], { stdio: 'pipe' })
|
||||
if (result.status === 0) {
|
||||
return
|
||||
}
|
||||
await delay(100)
|
||||
}
|
||||
throw new Error('Timed out while selecting the IBus Hangul engine')
|
||||
}
|
||||
|
||||
async function runInsideSession(evidenceDir) {
|
||||
const ibusLogPath = path.join(evidenceDir, 'ibus-daemon.log')
|
||||
const ibusLogFd = openSync(ibusLogPath, 'w')
|
||||
const windowManagerLogPath = path.join(evidenceDir, 'xfwm4.log')
|
||||
const windowManagerLogFd = openSync(windowManagerLogPath, 'w')
|
||||
const evidence = {
|
||||
display: process.env.DISPLAY ?? null,
|
||||
ibusDaemonPid: null,
|
||||
ibusGroupBeforeCleanup: [],
|
||||
ibusGroupAfterCleanup: [],
|
||||
playwrightPid: null,
|
||||
windowManagerPid: null,
|
||||
windowManagerGroupAfterCleanup: []
|
||||
}
|
||||
let ibusProcess
|
||||
let windowManagerProcess
|
||||
let testExitCode = 1
|
||||
|
||||
try {
|
||||
configureHangulEngine()
|
||||
windowManagerProcess = spawn('xfwm4', ['--compositor=off'], {
|
||||
detached: true,
|
||||
env: process.env,
|
||||
stdio: ['ignore', windowManagerLogFd, windowManagerLogFd]
|
||||
})
|
||||
if (!windowManagerProcess.pid) {
|
||||
throw new Error('xfwm4 did not return a PID')
|
||||
}
|
||||
evidence.windowManagerPid = windowManagerProcess.pid
|
||||
console.error(`[terminal-ime] started xfwm4 PID ${windowManagerProcess.pid}`)
|
||||
|
||||
ibusProcess = spawn(
|
||||
'ibus-daemon',
|
||||
['--xim', '--verbose', '--panel=disable', '--emoji-extension=disable'],
|
||||
{
|
||||
detached: true,
|
||||
env: process.env,
|
||||
stdio: ['ignore', ibusLogFd, ibusLogFd]
|
||||
}
|
||||
)
|
||||
if (!ibusProcess.pid) {
|
||||
throw new Error('ibus-daemon did not return a PID')
|
||||
}
|
||||
evidence.ibusDaemonPid = ibusProcess.pid
|
||||
console.error(`[terminal-ime] started ibus-daemon PID ${ibusProcess.pid}`)
|
||||
await waitForHangulEngine(ibusProcess)
|
||||
console.error(`[terminal-ime] IBus version: ${commandOutput('ibus', ['version'])}`)
|
||||
console.error(`[terminal-ime] IBus engine: ${commandOutput('ibus', ['engine'])}`)
|
||||
console.error(
|
||||
`[terminal-ime] Hangul initial mode: ${commandOutput('gsettings', [
|
||||
'get',
|
||||
'org.freedesktop.ibus.engine.hangul',
|
||||
'initial-input-mode'
|
||||
])}`
|
||||
)
|
||||
console.error(
|
||||
`[terminal-ime] Hangul keyboard: ${commandOutput('gsettings', [
|
||||
'get',
|
||||
'org.freedesktop.ibus.engine.hangul',
|
||||
'hangul-keyboard'
|
||||
])}`
|
||||
)
|
||||
evidence.ibusGroupBeforeCleanup = processGroupMembers(ibusProcess.pid)
|
||||
console.error(`[terminal-ime] owned IBus group: ${evidence.ibusGroupBeforeCleanup.join('; ')}`)
|
||||
|
||||
const testProcess = spawn(
|
||||
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
|
||||
[
|
||||
'run',
|
||||
'test:e2e:headful',
|
||||
'--workers=1',
|
||||
'--',
|
||||
'tests/e2e/terminal-ibus-hangul-native.spec.ts'
|
||||
],
|
||||
{
|
||||
cwd: projectDir,
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_E2E_FORWARD_APP_LOGS: '1',
|
||||
ORCA_E2E_NATIVE_IBUS_HANGUL: '1'
|
||||
},
|
||||
stdio: 'inherit'
|
||||
}
|
||||
)
|
||||
if (!testProcess.pid) {
|
||||
throw new Error('Playwright did not return a PID')
|
||||
}
|
||||
evidence.playwrightPid = testProcess.pid
|
||||
console.error(`[terminal-ime] started Playwright PID ${testProcess.pid}`)
|
||||
testExitCode = await waitForExit(testProcess)
|
||||
} finally {
|
||||
if (ibusProcess?.pid) {
|
||||
evidence.ibusGroupBeforeCleanup = processGroupMembers(ibusProcess.pid)
|
||||
evidence.ibusGroupAfterCleanup = await stopOwnedProcessGroup(ibusProcess.pid)
|
||||
}
|
||||
if (windowManagerProcess?.pid) {
|
||||
evidence.windowManagerGroupAfterCleanup = await stopOwnedProcessGroup(
|
||||
windowManagerProcess.pid
|
||||
)
|
||||
}
|
||||
closeSync(ibusLogFd)
|
||||
closeSync(windowManagerLogFd)
|
||||
mkdirSync(path.join(projectDir, 'test-results'), { recursive: true })
|
||||
copyFileSync(
|
||||
ibusLogPath,
|
||||
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-ibus.log')
|
||||
)
|
||||
copyFileSync(
|
||||
windowManagerLogPath,
|
||||
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-xfwm4.log')
|
||||
)
|
||||
writeFileSync(
|
||||
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-processes.json'),
|
||||
`${JSON.stringify(evidence, null, 2)}\n`
|
||||
)
|
||||
}
|
||||
|
||||
if (evidence.ibusGroupAfterCleanup.length > 0) {
|
||||
throw new Error(
|
||||
`Owned IBus processes survived cleanup: ${evidence.ibusGroupAfterCleanup.join('; ')}`
|
||||
)
|
||||
}
|
||||
if (evidence.windowManagerGroupAfterCleanup.length > 0) {
|
||||
throw new Error(
|
||||
`Owned window-manager processes survived cleanup: ${evidence.windowManagerGroupAfterCleanup.join('; ')}`
|
||||
)
|
||||
}
|
||||
return testExitCode
|
||||
}
|
||||
|
||||
async function runOuter() {
|
||||
if (process.platform !== 'linux') {
|
||||
throw new Error('The native IBus Hangul E2E runner requires Linux/X11')
|
||||
}
|
||||
|
||||
const evidenceDir = mkdtempSync(path.join(os.tmpdir(), 'orca-terminal-ime-e2e-'))
|
||||
const runtimeDir = path.join(evidenceDir, 'runtime')
|
||||
mkdirSync(runtimeDir, { mode: 0o700 })
|
||||
mkdirSync(path.join(evidenceDir, 'config'))
|
||||
mkdirSync(path.join(evidenceDir, 'cache'))
|
||||
console.error(`[terminal-ime] evidence directory: ${evidenceDir}`)
|
||||
|
||||
const sessionProcess = spawn(
|
||||
'xvfb-run',
|
||||
[
|
||||
'--auto-servernum',
|
||||
'dbus-run-session',
|
||||
'--',
|
||||
process.execPath,
|
||||
scriptPath,
|
||||
insideSessionFlag,
|
||||
evidenceDir
|
||||
],
|
||||
{
|
||||
cwd: projectDir,
|
||||
detached: true,
|
||||
env: {
|
||||
...process.env,
|
||||
GTK_IM_MODULE: 'ibus',
|
||||
IBUS_ENABLE_SYNC_MODE: '1',
|
||||
LANG: process.env.LANG || 'C.UTF-8',
|
||||
QT_IM_MODULE: 'ibus',
|
||||
XDG_CACHE_HOME: path.join(evidenceDir, 'cache'),
|
||||
XDG_CONFIG_HOME: path.join(evidenceDir, 'config'),
|
||||
XDG_RUNTIME_DIR: runtimeDir,
|
||||
XMODIFIERS: '@im=ibus'
|
||||
},
|
||||
stdio: 'inherit'
|
||||
}
|
||||
)
|
||||
if (!sessionProcess.pid) {
|
||||
throw new Error('xvfb-run did not return a PID')
|
||||
}
|
||||
console.error(`[terminal-ime] started isolated X11 session PID ${sessionProcess.pid}`)
|
||||
const exitCode = await waitForExit(sessionProcess)
|
||||
const remaining = await stopOwnedProcessGroup(sessionProcess.pid)
|
||||
if (remaining.length > 0) {
|
||||
throw new Error(`Owned X11 session processes survived cleanup: ${remaining.join('; ')}`)
|
||||
}
|
||||
return exitCode
|
||||
}
|
||||
|
||||
const insideSession = process.argv[2] === insideSessionFlag
|
||||
try {
|
||||
if (insideSession && !process.argv[3]) {
|
||||
throw new Error(`${insideSessionFlag} requires an evidence directory argument`)
|
||||
}
|
||||
process.exitCode = insideSession ? await runInsideSession(process.argv[3]) : await runOuter()
|
||||
} catch (error) {
|
||||
console.error(`[terminal-ime] ${error instanceof Error ? error.message : String(error)}`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
|
||||
describe('terminal IME e2e workflow', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/terminal-ime-e2e.yml'), 'utf8')
|
||||
)
|
||||
|
||||
it('runs for xterm patch and terminal IME regression changes', () => {
|
||||
expect(workflow.on.pull_request.paths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'config/patches/@xterm__xterm@6.1.0-beta.287.patch',
|
||||
'config/scripts/run-terminal-ibus-hangul-e2e.mjs',
|
||||
'src/renderer/src/components/terminal-pane/keyboard-handlers.ts',
|
||||
'src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx',
|
||||
'src/renderer/src/components/terminal-pane/pty-connection.ts',
|
||||
'src/renderer/src/components/terminal-pane/pty-connection.test.ts',
|
||||
'src/renderer/src/components/terminal-pane/terminal-ime-*',
|
||||
'src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts',
|
||||
'src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts',
|
||||
'src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts',
|
||||
'tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts',
|
||||
'tests/e2e/terminal-ibus-hangul-native.spec.ts',
|
||||
'tests/e2e/terminal-ime-*.ts'
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('installs native IBus Hangul and X11 input tools', () => {
|
||||
const runs = workflow.jobs['linux-x11'].steps
|
||||
.map((step) => step.run)
|
||||
.filter((run) => typeof run === 'string')
|
||||
const installRun = runs.find((run) => run.includes('apt-get install'))
|
||||
|
||||
expect(installRun).toBeDefined()
|
||||
expect(installRun).toContain('ibus-hangul')
|
||||
expect(installRun).toContain('xdotool')
|
||||
expect(installRun).toContain('xfwm4')
|
||||
expect(installRun).toContain('xvfb')
|
||||
expect(installRun).toContain('dbus-x11')
|
||||
expect(installRun).toContain('dconf-gsettings-backend')
|
||||
expect(installRun).toContain('libglib2.0-bin')
|
||||
})
|
||||
|
||||
it('runs deterministic boundaries before the real IBus suite', () => {
|
||||
const runs = workflow.jobs['linux-x11'].steps
|
||||
.map((step) => step.run)
|
||||
.filter((run) => typeof run === 'string')
|
||||
const deterministicIndex = runs.findIndex((run) =>
|
||||
run.includes('terminal-ime-exact-byte.spec.ts')
|
||||
)
|
||||
const nativeIndex = runs.findIndex((run) => run.includes('test:e2e:terminal-ime-native'))
|
||||
|
||||
expect(deterministicIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(nativeIndex).toBeGreaterThan(deterministicIndex)
|
||||
})
|
||||
|
||||
it('keeps IBus lifecycle scoped to owned processes', () => {
|
||||
const runner = readFileSync(
|
||||
join(projectDir, 'config/scripts/run-terminal-ibus-hangul-e2e.mjs'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(runner).toContain(
|
||||
"['--xim', '--verbose', '--panel=disable', '--emoji-extension=disable']"
|
||||
)
|
||||
expect(runner).toContain("spawn('xfwm4', ['--compositor=off']")
|
||||
expect(runner).toContain("['initial-input-mode', 'hangul']")
|
||||
expect(runner).toContain("['hangul-keyboard', '2']")
|
||||
expect(runner).toContain("process.kill(-processGroupId, 'SIGTERM')")
|
||||
expect(runner).toContain("process.kill(-processGroupId, 'SIGKILL')")
|
||||
expect(runner).toContain('const killDeadline = Date.now() + processKillTimeoutMs')
|
||||
expect(runner).toMatch(
|
||||
/'test:e2e:headful',\s*'--workers=1',\s*'--',\s*'tests\/e2e\/terminal-ibus-hangul-native\.spec\.ts'/
|
||||
)
|
||||
expect(runner).not.toContain("'--replace'")
|
||||
expect(runner).not.toContain('killall')
|
||||
expect(runner).not.toContain('pkill')
|
||||
})
|
||||
|
||||
it('bounds blocking native input commands', () => {
|
||||
const nativeSpec = readFileSync(
|
||||
join(projectDir, 'tests/e2e/terminal-ibus-hangul-native.spec.ts'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(nativeSpec.match(/timeout: NATIVE_COMMAND_TIMEOUT_MS/g)).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
|
@ -105,6 +105,7 @@
|
|||
"win-crash-survival-e2e": "node tools/win-crash-survival-e2e/run.mjs",
|
||||
"test:e2e:ssh-codex-artifacts-repro": "node config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs",
|
||||
"test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful",
|
||||
"test:e2e:terminal-ime-native": "node config/scripts/run-terminal-ibus-hangul-e2e.mjs",
|
||||
"test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts",
|
||||
"bench:idle-cpu": "pnpm run ensure:electron-runtime && node config/scripts/run-idle-cpu-benchmark.mjs",
|
||||
"bench:startup": "pnpm run ensure:electron-runtime && node tools/benchmarks/startup-time-bench.mjs",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ patchedDependencies:
|
|||
hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258
|
||||
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
|
||||
'@xterm/xterm@6.1.0-beta.287':
|
||||
hash: 8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3
|
||||
hash: 082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6
|
||||
path: config/patches/@xterm__xterm@6.1.0-beta.287.patch
|
||||
node-pty@1.1.0:
|
||||
hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8
|
||||
|
|
@ -46,7 +46,7 @@ importers:
|
|||
version: 2.5.6
|
||||
'@xterm/addon-serialize':
|
||||
specifier: 0.15.0-beta.287
|
||||
version: 0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
|
||||
version: 0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
|
||||
'@xterm/headless':
|
||||
specifier: 6.1.0-beta.287
|
||||
version: 6.1.0-beta.287
|
||||
|
|
@ -206,25 +206,25 @@ importers:
|
|||
version: 5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))
|
||||
'@xterm/addon-fit':
|
||||
specifier: 0.12.0-beta.287
|
||||
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
|
||||
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
|
||||
'@xterm/addon-ligatures':
|
||||
specifier: 0.11.0-beta.287
|
||||
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
|
||||
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
|
||||
'@xterm/addon-search':
|
||||
specifier: 0.17.0-beta.287
|
||||
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
|
||||
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
|
||||
'@xterm/addon-unicode11':
|
||||
specifier: 0.10.0-beta.287
|
||||
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
|
||||
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
|
||||
'@xterm/addon-web-links':
|
||||
specifier: 0.13.0-beta.287
|
||||
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
|
||||
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
|
||||
'@xterm/addon-webgl':
|
||||
specifier: 0.20.0-beta.286
|
||||
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
|
||||
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
|
||||
'@xterm/xterm':
|
||||
specifier: 6.1.0-beta.287
|
||||
version: 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
|
||||
version: 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
|
|
@ -9179,39 +9179,39 @@ snapshots:
|
|||
|
||||
'@xmldom/xmldom@0.8.13': {}
|
||||
|
||||
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
|
||||
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
|
||||
|
||||
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
|
||||
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
|
||||
lru-cache: 11.5.1
|
||||
opentype.js: 2.0.0
|
||||
|
||||
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
|
||||
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
|
||||
|
||||
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
|
||||
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
|
||||
|
||||
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
|
||||
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
|
||||
|
||||
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
|
||||
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
|
||||
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
|
||||
|
||||
'@xterm/headless@6.1.0-beta.287': {}
|
||||
|
||||
'@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)': {}
|
||||
'@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)': {}
|
||||
|
||||
abbrev@4.0.0: {}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,277 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import type { PtyTransport } from './pty-transport'
|
||||
import { useTerminalKeyboardShortcuts } from './keyboard-handlers'
|
||||
import {
|
||||
installTerminalImeCompositionRoute,
|
||||
XTERM_COMPOSITION_SESSION_START_EVENT
|
||||
} from './terminal-ime-composition-route'
|
||||
|
||||
type KeyboardHandlersDeps = Parameters<typeof useTerminalKeyboardShortcuts>[0]
|
||||
|
||||
function keyboardEvent(
|
||||
type: 'keydown' | 'keyup',
|
||||
overrides: KeyboardEventInit & { keyCode: number; timeStamp: number; isComposing?: boolean }
|
||||
): KeyboardEvent {
|
||||
const event = new KeyboardEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...overrides
|
||||
})
|
||||
Object.defineProperties(event, {
|
||||
isComposing: { value: overrides.isComposing ?? false },
|
||||
keyCode: { value: overrides.keyCode },
|
||||
timeStamp: { value: overrides.timeStamp }
|
||||
})
|
||||
return event
|
||||
}
|
||||
|
||||
function createHarness(): {
|
||||
deps: KeyboardHandlersDeps
|
||||
editable: HTMLInputElement
|
||||
startComposition: () => void
|
||||
terminalInput: HTMLTextAreaElement
|
||||
dispose: () => void
|
||||
} {
|
||||
const scope = document.createElement('div')
|
||||
const terminalElement = document.createElement('div')
|
||||
const terminalInput = document.createElement('textarea')
|
||||
const editable = document.createElement('input')
|
||||
terminalInput.className = 'xterm-helper-textarea'
|
||||
terminalElement.append(terminalInput)
|
||||
scope.append(terminalElement, editable)
|
||||
document.body.append(scope)
|
||||
|
||||
const sendInput = vi.fn(() => true)
|
||||
const transport = {
|
||||
getPtyId: () => 'pty-1',
|
||||
sendInput
|
||||
} as unknown as PtyTransport
|
||||
const pane = {
|
||||
id: 1,
|
||||
leafId: '00000000-0000-4000-8000-000000000001',
|
||||
terminal: {
|
||||
element: terminalElement,
|
||||
focus: vi.fn(),
|
||||
getSelection: vi.fn(() => '')
|
||||
}
|
||||
}
|
||||
const manager = {
|
||||
getActivePane: () => pane,
|
||||
getPanes: () => [pane]
|
||||
} as unknown as PaneManager
|
||||
const route = installTerminalImeCompositionRoute({
|
||||
terminalElement,
|
||||
terminal: { input: vi.fn() },
|
||||
capturedTransport: transport,
|
||||
getCurrentTransport: () => transport
|
||||
})
|
||||
const deps: KeyboardHandlersDeps = {
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'worktree-1',
|
||||
isActive: true,
|
||||
keyboardScopeRef: { current: scope },
|
||||
managerRef: { current: manager },
|
||||
paneTransportsRef: { current: new Map([[pane.id, transport]]) },
|
||||
panePtyBindingsRef: { current: new Map() },
|
||||
paneCwdRef: { current: new Map() },
|
||||
fallbackCwd: '',
|
||||
expandedPaneIdRef: { current: null },
|
||||
setExpandedPane: vi.fn(),
|
||||
restoreExpandedLayout: vi.fn(),
|
||||
refreshPaneSizes: vi.fn(),
|
||||
persistLayoutSnapshot: vi.fn(),
|
||||
toggleExpandPane: vi.fn(),
|
||||
setSearchOpen: vi.fn(),
|
||||
onSearchSelectedText: vi.fn(),
|
||||
onRequestClosePane: vi.fn(),
|
||||
onClearPaneScrollback: vi.fn(),
|
||||
onSetTitle: vi.fn(),
|
||||
onClearPaneTitle: vi.fn(),
|
||||
searchOpenRef: { current: false },
|
||||
searchStateRef: { current: { query: '', caseSensitive: false, regex: false } },
|
||||
macOptionAsAltRef: { current: 'false' }
|
||||
}
|
||||
return {
|
||||
deps,
|
||||
editable,
|
||||
terminalInput,
|
||||
startComposition: () => {
|
||||
terminalElement.dispatchEvent(
|
||||
new CustomEvent(XTERM_COMPOSITION_SESSION_START_EVENT, {
|
||||
detail: { id: 1 }
|
||||
})
|
||||
)
|
||||
},
|
||||
dispose: () => {
|
||||
route.dispose()
|
||||
scope.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('Windows IME keyboard ownership', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('Windows')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ key: 'Shift', code: 'ShiftLeft', keyCode: 16, modifier: { shiftKey: true } },
|
||||
{ key: 'Control', code: 'ControlLeft', keyCode: 17, modifier: { ctrlKey: true } }
|
||||
])('absorbs a bare Enter redispatch when $key was held before composition', (held) => {
|
||||
const harness = createHarness()
|
||||
const hook = renderHook(() => useTerminalKeyboardShortcuts(harness.deps))
|
||||
harness.terminalInput.dispatchEvent(
|
||||
keyboardEvent('keydown', {
|
||||
key: held.key,
|
||||
code: held.code,
|
||||
keyCode: held.keyCode,
|
||||
timeStamp: 1,
|
||||
...held.modifier
|
||||
})
|
||||
)
|
||||
harness.startComposition()
|
||||
harness.terminalInput.dispatchEvent(
|
||||
keyboardEvent('keydown', {
|
||||
key: 'Process',
|
||||
code: 'Enter',
|
||||
keyCode: 229,
|
||||
timeStamp: 10,
|
||||
isComposing: true,
|
||||
...held.modifier
|
||||
})
|
||||
)
|
||||
|
||||
const redispatch = keyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
keyCode: 13,
|
||||
timeStamp: 20
|
||||
})
|
||||
harness.terminalInput.dispatchEvent(redispatch)
|
||||
|
||||
expect(redispatch.defaultPrevented).toBe(true)
|
||||
hook.unmount()
|
||||
harness.dispose()
|
||||
})
|
||||
|
||||
it('does not route an editable-target Enter keyup into the terminal', () => {
|
||||
const harness = createHarness()
|
||||
const hook = renderHook(() => useTerminalKeyboardShortcuts(harness.deps))
|
||||
harness.startComposition()
|
||||
const keyup = keyboardEvent('keyup', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
keyCode: 13,
|
||||
timeStamp: 10,
|
||||
ctrlKey: true
|
||||
})
|
||||
|
||||
harness.editable.dispatchEvent(keyup)
|
||||
|
||||
expect(keyup.defaultPrevented).toBe(false)
|
||||
hook.unmount()
|
||||
harness.dispose()
|
||||
})
|
||||
|
||||
it('does not arm a modifier pressed in an editable control', () => {
|
||||
const harness = createHarness()
|
||||
const hook = renderHook(() => useTerminalKeyboardShortcuts(harness.deps))
|
||||
harness.editable.dispatchEvent(
|
||||
keyboardEvent('keydown', {
|
||||
key: 'Control',
|
||||
code: 'ControlLeft',
|
||||
keyCode: 17,
|
||||
timeStamp: 1,
|
||||
ctrlKey: true
|
||||
})
|
||||
)
|
||||
harness.startComposition()
|
||||
harness.terminalInput.dispatchEvent(
|
||||
keyboardEvent('keydown', {
|
||||
key: 'Process',
|
||||
code: 'Enter',
|
||||
keyCode: 229,
|
||||
timeStamp: 10,
|
||||
isComposing: true,
|
||||
ctrlKey: true
|
||||
})
|
||||
)
|
||||
const redispatch = keyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
keyCode: 13,
|
||||
timeStamp: 20
|
||||
})
|
||||
|
||||
harness.terminalInput.dispatchEvent(redispatch)
|
||||
|
||||
expect(redispatch.defaultPrevented).toBe(false)
|
||||
hook.unmount()
|
||||
harness.dispose()
|
||||
})
|
||||
|
||||
it('retains Ctrl ownership when a later-held Shift is released first', () => {
|
||||
const harness = createHarness()
|
||||
const hook = renderHook(() => useTerminalKeyboardShortcuts(harness.deps))
|
||||
for (const event of [
|
||||
keyboardEvent('keydown', {
|
||||
key: 'Control',
|
||||
code: 'ControlLeft',
|
||||
keyCode: 17,
|
||||
timeStamp: 1,
|
||||
ctrlKey: true
|
||||
}),
|
||||
keyboardEvent('keydown', {
|
||||
key: 'Shift',
|
||||
code: 'ShiftLeft',
|
||||
keyCode: 16,
|
||||
timeStamp: 2,
|
||||
ctrlKey: true,
|
||||
shiftKey: true
|
||||
}),
|
||||
keyboardEvent('keyup', {
|
||||
key: 'Shift',
|
||||
code: 'ShiftLeft',
|
||||
keyCode: 16,
|
||||
timeStamp: 3,
|
||||
ctrlKey: true
|
||||
})
|
||||
]) {
|
||||
harness.terminalInput.dispatchEvent(event)
|
||||
}
|
||||
harness.startComposition()
|
||||
harness.terminalInput.dispatchEvent(
|
||||
keyboardEvent('keydown', {
|
||||
key: 'Process',
|
||||
code: 'Enter',
|
||||
keyCode: 229,
|
||||
timeStamp: 10,
|
||||
isComposing: true,
|
||||
ctrlKey: true
|
||||
})
|
||||
)
|
||||
const redispatch = keyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
keyCode: 13,
|
||||
timeStamp: 20
|
||||
})
|
||||
|
||||
harness.terminalInput.dispatchEvent(redispatch)
|
||||
|
||||
expect(redispatch.defaultPrevented).toBe(true)
|
||||
hook.unmount()
|
||||
harness.dispose()
|
||||
})
|
||||
})
|
||||
|
|
@ -9,6 +9,18 @@ import { safeFind } from '../terminal-search-safe-find'
|
|||
import { resolveTerminalShortcutAction } from './terminal-shortcut-policy'
|
||||
import type { MacOptionAsAlt } from './terminal-shortcut-policy'
|
||||
import { createTerminalNativeOnlyShortcutTracker } from './terminal-native-only-shortcut'
|
||||
import {
|
||||
createTerminalImeDeferredNewlineSender,
|
||||
createTerminalImeModifiedEnterChordOwner,
|
||||
getTerminalImeModifiedEnterKind,
|
||||
isTerminalImeEnterKeyUp,
|
||||
isTerminalImeProcessEnter
|
||||
} from './terminal-ime-deferred-newline'
|
||||
import { hasPendingTerminalImeComposition } from './terminal-ime-composition-route'
|
||||
import {
|
||||
requestCapturedTerminalReconfirmation,
|
||||
sendCapturedTerminalInput
|
||||
} from './terminal-captured-input-dispatch'
|
||||
import {
|
||||
keybindingMatchesAction,
|
||||
type KeybindingOverrides,
|
||||
|
|
@ -264,15 +276,40 @@ export function useTerminalKeyboardShortcuts({
|
|||
// held. To distinguish left vs right Option, we record the Option key's
|
||||
// location from its own keydown event and clear it on keyup.
|
||||
let optionKeyLocation = 0
|
||||
const heldImeEnterModifiers = new Set<'shift' | 'ctrl'>()
|
||||
const nativeOnlyShortcutTracker = createTerminalNativeOnlyShortcutTracker()
|
||||
const deferredNewlineSender = createTerminalImeDeferredNewlineSender()
|
||||
const modifiedEnterChordOwner = createTerminalImeModifiedEnterChordOwner()
|
||||
const getHeldImeEnterModifier = () =>
|
||||
heldImeEnterModifiers.size === 1
|
||||
? (heldImeEnterModifiers.values().next().value ?? null)
|
||||
: null
|
||||
const getImeEnterModifier = (event: KeyboardEvent) => {
|
||||
const eventKind = getTerminalImeModifiedEnterKind(event)
|
||||
if (eventKind || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) {
|
||||
return eventKind
|
||||
}
|
||||
return getHeldImeEnterModifier()
|
||||
}
|
||||
const getModifiedEnterChord = (event: KeyboardEvent) => {
|
||||
const kind = getImeEnterModifier(event)
|
||||
return kind ? { kind, code: event.code, timeStamp: event.timeStamp } : null
|
||||
}
|
||||
const onModifierDown = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Alt') {
|
||||
optionKeyLocation = e.location
|
||||
}
|
||||
}
|
||||
const onModifierUp = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Alt') {
|
||||
optionKeyLocation = 0
|
||||
if (isWindows && (e.key === 'Shift' || e.key === 'Control')) {
|
||||
const manager = managerRef.current
|
||||
const keyboardScope = keyboardScopeRef.current
|
||||
const pane = manager?.getActivePane() ?? manager?.getPanes()[0]
|
||||
if (
|
||||
pane &&
|
||||
(!keyboardScope || keyboardEventBelongsToScope(e, keyboardScope)) &&
|
||||
!isEditableTarget(e.target)
|
||||
) {
|
||||
heldImeEnterModifiers.add(e.key === 'Shift' ? 'shift' : 'ctrl')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -304,6 +341,94 @@ export function useTerminalKeyboardShortcuts({
|
|||
)
|
||||
}
|
||||
|
||||
// Why: the active pane's live PTY session decides whether Ctrl+Arrow should
|
||||
// pass through as native \e[1;5C/\e[1;5D or be translated to \eb/\ef.
|
||||
// Resolved lazily so session/runtime lookups stay off other keystrokes.
|
||||
const isLocalWindowsConptyPane = (): boolean => {
|
||||
const manager = managerRef.current
|
||||
const activePane = manager?.getActivePane() ?? manager?.getPanes()[0]
|
||||
if (!activePane) {
|
||||
return false
|
||||
}
|
||||
const storeState = useAppStore.getState()
|
||||
return isLocalWindowsConptyPaneForCtrlArrow({
|
||||
isWindows,
|
||||
userAgent: navigator.userAgent,
|
||||
state: storeState,
|
||||
worktreeId,
|
||||
tabId,
|
||||
paneId: activePane.id,
|
||||
paneCwd: paneCwdRef.current,
|
||||
fallbackCwd,
|
||||
transport: paneTransportsRef.current.get(activePane.id) ?? null
|
||||
})
|
||||
}
|
||||
|
||||
// Why: the pane's TUI opted into kitty keyboard reporting via CSI > u;
|
||||
// the tracker mirrors that from PTY output so the policy can encode
|
||||
// Option chords the way the application negotiated.
|
||||
const isKittyKeyboardActivePane = (): boolean => {
|
||||
const manager = managerRef.current
|
||||
const activePane = manager?.getActivePane() ?? manager?.getPanes()[0]
|
||||
if (!activePane) {
|
||||
return false
|
||||
}
|
||||
return (paneKittyKeyboardModesRef?.current.get(activePane.id)?.flags ?? 0) > 0
|
||||
}
|
||||
|
||||
const resolveShortcutEvent = (
|
||||
event: Parameters<typeof resolveTerminalKeyboardShortcutAction>[0]
|
||||
): ReturnType<typeof resolveTerminalKeyboardShortcutAction> =>
|
||||
resolveTerminalKeyboardShortcutAction(
|
||||
event,
|
||||
isMac,
|
||||
macOptionAsAltRef.current,
|
||||
optionKeyLocation,
|
||||
isWindows,
|
||||
keybindings,
|
||||
isLocalWindowsConptyPane,
|
||||
isKittyKeyboardActivePane,
|
||||
getLayoutBaseCharacterForCode,
|
||||
getActivePaneWindowsShiftEnterEncoding,
|
||||
isActivePaneWindowsTerminalHost,
|
||||
terminalShortcutPolicy
|
||||
)
|
||||
|
||||
const createCapturedInputSender = (
|
||||
pane: { id: number; leafId: string },
|
||||
data: string
|
||||
): (() => void) => {
|
||||
const capturedTransport = paneTransportsRef.current.get(pane.id)
|
||||
const capturedPtyId = capturedTransport?.getPtyId() ?? null
|
||||
const capturedBinding = panePtyBindingsRef.current.get(pane.id) as
|
||||
| (IDisposable & { requestDroidReconfirmation?: () => void })
|
||||
| undefined
|
||||
const getCurrentManager = () => managerRef.current
|
||||
const getCurrentTransport = () => paneTransportsRef.current.get(pane.id)
|
||||
const getCurrentBinding = () => panePtyBindingsRef.current.get(pane.id)
|
||||
return () => {
|
||||
const targetPaneMounted =
|
||||
getCurrentManager()
|
||||
?.getPanes()
|
||||
.some((candidate) => candidate.id === pane.id && candidate.leafId === pane.leafId) ===
|
||||
true
|
||||
const sent = sendCapturedTerminalInput({
|
||||
targetPaneMounted,
|
||||
currentTransport: getCurrentTransport(),
|
||||
capturedTransport,
|
||||
capturedPtyId,
|
||||
data
|
||||
})
|
||||
if (sent) {
|
||||
recordTerminalUserInputForLeaf(tabId, pane.leafId)
|
||||
if (data === '\x1b[13;2u') {
|
||||
// Why: this write bypasses PTY onData, so no-OSC shells need reconfirmation.
|
||||
requestCapturedTerminalReconfirmation(getCurrentBinding(), capturedBinding)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent): void => {
|
||||
// Why: replace stale state only for this physical key so rollover cannot
|
||||
// disarm a still-held native-only chord before its Kitty keyup arrives.
|
||||
|
|
@ -317,6 +442,20 @@ export function useTerminalKeyboardShortcuts({
|
|||
return
|
||||
}
|
||||
|
||||
const modifiedEnterChord = isWindows ? getModifiedEnterChord(e) : null
|
||||
if (
|
||||
e.key === 'Enter' &&
|
||||
e.keyCode === 13 &&
|
||||
!e.isComposing &&
|
||||
((modifiedEnterChord && modifiedEnterChordOwner.absorb(modifiedEnterChord)) ||
|
||||
deferredNewlineSender.absorbRedispatchedEnter(e))
|
||||
) {
|
||||
// Chromium can drop the modifier when re-dispatching the committing Enter.
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
return
|
||||
}
|
||||
|
||||
if (matchFileSearchShortcut(e, shortcutPlatform, keybindings, terminalShortcutPolicy)) {
|
||||
const pane = manager.getActivePane() ?? manager.getPanes()[0]
|
||||
const selectedText = normalizeSelectedTextForFileSearch(pane?.terminal.getSelection())
|
||||
|
|
@ -356,53 +495,23 @@ export function useTerminalKeyboardShortcuts({
|
|||
return
|
||||
}
|
||||
|
||||
// Why: the active pane's live PTY session decides whether Ctrl+Arrow should
|
||||
// pass through as native \e[1;5C/\e[1;5D or be translated to \eb/\ef.
|
||||
// Resolved lazily so session/runtime lookups stay off other keystrokes.
|
||||
const isLocalWindowsConptyPane = (): boolean => {
|
||||
const activePane = manager.getActivePane() ?? manager.getPanes()[0]
|
||||
if (!activePane) {
|
||||
return false
|
||||
}
|
||||
const storeState = useAppStore.getState()
|
||||
return isLocalWindowsConptyPaneForCtrlArrow({
|
||||
isWindows,
|
||||
userAgent: navigator.userAgent,
|
||||
state: storeState,
|
||||
worktreeId,
|
||||
tabId,
|
||||
paneId: activePane.id,
|
||||
paneCwd: paneCwdRef.current,
|
||||
fallbackCwd,
|
||||
transport: paneTransportsRef.current.get(activePane.id) ?? null
|
||||
})
|
||||
}
|
||||
|
||||
// Why: the pane's TUI opted into kitty keyboard reporting via CSI > u;
|
||||
// the tracker mirrors that from PTY output so the policy can encode
|
||||
// Option chords the way the application negotiated.
|
||||
const isKittyKeyboardActivePane = (): boolean => {
|
||||
const activePane = manager.getActivePane() ?? manager.getPanes()[0]
|
||||
if (!activePane) {
|
||||
return false
|
||||
}
|
||||
return (paneKittyKeyboardModesRef?.current.get(activePane.id)?.flags ?? 0) > 0
|
||||
}
|
||||
|
||||
const action = resolveTerminalKeyboardShortcutAction(
|
||||
e,
|
||||
isMac,
|
||||
macOptionAsAltRef.current,
|
||||
optionKeyLocation,
|
||||
isWindows,
|
||||
keybindings,
|
||||
isLocalWindowsConptyPane,
|
||||
isKittyKeyboardActivePane,
|
||||
getLayoutBaseCharacterForCode,
|
||||
getActivePaneWindowsShiftEnterEncoding,
|
||||
isActivePaneWindowsTerminalHost,
|
||||
terminalShortcutPolicy
|
||||
const terminalPaneForImeShortcut = manager.getActivePane() ?? manager.getPanes()[0]
|
||||
const hasPendingImeComposition = hasPendingTerminalImeComposition(
|
||||
terminalPaneForImeShortcut?.terminal.element
|
||||
)
|
||||
const imeProcessEnter = isWindows && hasPendingImeComposition && isTerminalImeProcessEnter(e)
|
||||
const shortcutEvent = imeProcessEnter
|
||||
? {
|
||||
key: 'Enter',
|
||||
code: e.code,
|
||||
metaKey: e.metaKey,
|
||||
ctrlKey: e.ctrlKey,
|
||||
altKey: e.altKey,
|
||||
shiftKey: e.shiftKey,
|
||||
repeat: e.repeat
|
||||
}
|
||||
: e
|
||||
const action = resolveShortcutEvent(shortcutEvent)
|
||||
if (!action) {
|
||||
return
|
||||
}
|
||||
|
|
@ -422,18 +531,18 @@ export function useTerminalKeyboardShortcuts({
|
|||
if (!pane) {
|
||||
return
|
||||
}
|
||||
const sent = paneTransportsRef.current.get(pane.id)?.sendInput(action.data) === true
|
||||
if (sent) {
|
||||
recordTerminalUserInputForLeaf(tabId, pane.leafId)
|
||||
if (action.data === '\x1b[13;2u') {
|
||||
// Why: this direct shortcut write does not pass through PTY onData,
|
||||
// so no-OSC shells need an explicit post-write confirmation ladder.
|
||||
const binding = panePtyBindingsRef.current.get(pane.id) as
|
||||
| (IDisposable & { requestDroidReconfirmation?: () => void })
|
||||
| undefined
|
||||
binding?.requestDroidReconfirmation?.()
|
||||
const sendResolvedInput = createCapturedInputSender(pane, action.data)
|
||||
if ((e.isComposing || hasPendingImeComposition) && (e.key === 'Enter' || imeProcessEnter)) {
|
||||
if (isWindows) {
|
||||
const chord = getModifiedEnterChord(e)
|
||||
if (chord && !modifiedEnterChordOwner.claim(chord)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
deferredNewlineSender.defer(e, pane.terminal.element, sendResolvedInput)
|
||||
return
|
||||
}
|
||||
sendResolvedInput()
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -626,6 +735,74 @@ export function useTerminalKeyboardShortcuts({
|
|||
}
|
||||
}
|
||||
|
||||
const onKeyUp = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Alt') {
|
||||
optionKeyLocation = 0
|
||||
}
|
||||
const releasedImeEnterModifier =
|
||||
e.key === 'Shift' ? 'shift' : e.key === 'Control' ? 'ctrl' : null
|
||||
if (releasedImeEnterModifier) {
|
||||
const kind = releasedImeEnterModifier
|
||||
heldImeEnterModifiers.delete(kind)
|
||||
modifiedEnterChordOwner.release({ kind, code: e.code, timeStamp: e.timeStamp })
|
||||
}
|
||||
if (e.key !== 'Enter') {
|
||||
return
|
||||
}
|
||||
|
||||
const modifiedEnterKind = getImeEnterModifier(e)
|
||||
if (isWindows && modifiedEnterKind && isTerminalImeEnterKeyUp(e)) {
|
||||
const chord = { kind: modifiedEnterKind, code: e.code, timeStamp: e.timeStamp }
|
||||
if (modifiedEnterChordOwner.absorb(chord)) {
|
||||
modifiedEnterChordOwner.release(chord)
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
deferredNewlineSender.releaseRedispatchedEnter(e)
|
||||
return
|
||||
}
|
||||
|
||||
const manager = managerRef.current
|
||||
const keyboardScope = keyboardScopeRef.current
|
||||
if (
|
||||
manager &&
|
||||
!isEditableTarget(e.target) &&
|
||||
(!keyboardScope || keyboardEventBelongsToScope(e, keyboardScope))
|
||||
) {
|
||||
const pane = manager.getActivePane() ?? manager.getPanes()[0]
|
||||
if (pane && hasPendingTerminalImeComposition(pane.terminal.element)) {
|
||||
const action = resolveShortcutEvent({
|
||||
key: 'Enter',
|
||||
code: e.code,
|
||||
metaKey: false,
|
||||
ctrlKey: modifiedEnterKind === 'ctrl',
|
||||
altKey: false,
|
||||
shiftKey: modifiedEnterKind === 'shift',
|
||||
repeat: false
|
||||
})
|
||||
if (action?.type === 'sendInput') {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
deferredNewlineSender.defer(
|
||||
e,
|
||||
pane.terminal.element,
|
||||
createCapturedInputSender(pane, action.data)
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (modifiedEnterKind) {
|
||||
modifiedEnterChordOwner.release({
|
||||
kind: modifiedEnterKind,
|
||||
code: e.code,
|
||||
timeStamp: e.timeStamp
|
||||
})
|
||||
}
|
||||
deferredNewlineSender.releaseRedispatchedEnter(e)
|
||||
}
|
||||
|
||||
const onNativeOnlyShortcutCompanion = (e: KeyboardEvent): void => {
|
||||
if (nativeOnlyShortcutTracker.consumeCompanion(e)) {
|
||||
// Why: canceling only the companion keypress prevents Chromium's text
|
||||
|
|
@ -649,18 +826,23 @@ export function useTerminalKeyboardShortcuts({
|
|||
|
||||
const onNativeOnlyBlur = (): void => {
|
||||
nativeOnlyShortcutTracker.clear()
|
||||
heldImeEnterModifiers.clear()
|
||||
modifiedEnterChordOwner.clear()
|
||||
deferredNewlineSender.clearRedispatchedEnters()
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onModifierDown, { capture: true })
|
||||
window.addEventListener('keyup', onModifierUp, { capture: true })
|
||||
window.addEventListener('keyup', onKeyUp, { capture: true })
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
window.addEventListener('keypress', onNativeOnlyShortcutCompanion, { capture: true })
|
||||
window.addEventListener('keyup', onNativeOnlyShortcutCompanion, { capture: true })
|
||||
window.addEventListener('beforeinput', onNativeOnlyBeforeInput, { capture: true })
|
||||
window.addEventListener('blur', onNativeOnlyBlur)
|
||||
return () => {
|
||||
modifiedEnterChordOwner.clear()
|
||||
deferredNewlineSender.clearRedispatchedEnters()
|
||||
window.removeEventListener('keydown', onModifierDown, { capture: true })
|
||||
window.removeEventListener('keyup', onModifierUp, { capture: true })
|
||||
window.removeEventListener('keyup', onKeyUp, { capture: true })
|
||||
window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
window.removeEventListener('keypress', onNativeOnlyShortcutCompanion, { capture: true })
|
||||
window.removeEventListener('keyup', onNativeOnlyShortcutCompanion, { capture: true })
|
||||
|
|
|
|||
|
|
@ -5054,8 +5054,12 @@ describe('connectPanePty', () => {
|
|||
|
||||
connectPanePty(pane as never, createManager(1) as never, createDeps() as never)
|
||||
expect(terminalTarget.handlers.size).toBe(1)
|
||||
expect(terminalTarget.target.addEventListener).toHaveBeenCalledTimes(2)
|
||||
expect(terminalTarget.target.removeEventListener).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
terminalTarget.target.addEventListener.mock.calls.filter(([type]) => type === 'keydown')
|
||||
).toHaveLength(2)
|
||||
expect(
|
||||
terminalTarget.target.removeEventListener.mock.calls.filter(([type]) => type === 'keydown')
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('clears the mobile-fit pane binding when the pane connection is disposed', async () => {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager'
|
|||
import type { ManagedPaneInternal } from '@/lib/pane-manager/pane-manager-types'
|
||||
import type { IBuffer, IDisposable } from '@xterm/xterm'
|
||||
import { resolveCursorAgentImeAnchor } from '@/lib/pane-manager/terminal-ime-anchor'
|
||||
import { installTerminalImeCompositionRoute } from './terminal-ime-composition-route'
|
||||
import { detectAgentStatusFromTitle, agentTypeToIconAgent, isClaudeAgent } from '@/lib/agent-status'
|
||||
import { resolvePaneTitleDecision } from './terminal-title-evidence'
|
||||
import { blocksCodexPaneInput } from '../codex-restart-notice-state'
|
||||
|
|
@ -4021,6 +4022,12 @@ export function connectPanePty(
|
|||
requestRecoveryForUndeliverableInput()
|
||||
}
|
||||
})
|
||||
const imeCompositionRouteDisposable = installTerminalImeCompositionRoute({
|
||||
terminalElement: pane.terminal.element,
|
||||
terminal: pane.terminal,
|
||||
capturedTransport: transport,
|
||||
getCurrentTransport: () => deps.paneTransportsRef.current.get(pane.id)
|
||||
})
|
||||
|
||||
const shouldSuppressDesktopPtyResize = (): boolean => {
|
||||
const currentPtyId = transport.getPtyId()
|
||||
|
|
@ -8838,6 +8845,7 @@ export function connectPanePty(
|
|||
clearTimeout(connectFallbackTimer)
|
||||
connectFallbackTimer = null
|
||||
}
|
||||
imeCompositionRouteDisposable.dispose()
|
||||
onDataDisposable.dispose()
|
||||
userInputActivityDisposable?.dispose()
|
||||
terminalCapabilityRepliesDisposable.dispose()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { PtyTransport } from './pty-transport'
|
||||
import {
|
||||
requestCapturedTerminalReconfirmation,
|
||||
sendCapturedTerminalInput
|
||||
} from './terminal-captured-input-dispatch'
|
||||
|
||||
function createTransport(ptyId: string | null): PtyTransport {
|
||||
return {
|
||||
getPtyId: vi.fn(() => ptyId),
|
||||
sendInput: vi.fn(() => true)
|
||||
} as unknown as PtyTransport
|
||||
}
|
||||
|
||||
describe('sendCapturedTerminalInput', () => {
|
||||
it.each(['local IPC', 'SSH remote runtime'])(
|
||||
'sends on the captured %s route while its PTY still owns the mounted pane',
|
||||
() => {
|
||||
const transport = createTransport('pty-original')
|
||||
|
||||
expect(
|
||||
sendCapturedTerminalInput({
|
||||
targetPaneMounted: true,
|
||||
currentTransport: transport,
|
||||
capturedTransport: transport,
|
||||
capturedPtyId: 'pty-original',
|
||||
data: '\r'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(transport.sendInput).toHaveBeenCalledWith('\r')
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['local IPC', 'SSH remote runtime'])(
|
||||
'does not deliver to a replacement %s transport for a reused pane',
|
||||
() => {
|
||||
const original = createTransport('pty-original')
|
||||
const replacement = createTransport('pty-replacement')
|
||||
|
||||
expect(
|
||||
sendCapturedTerminalInput({
|
||||
targetPaneMounted: true,
|
||||
currentTransport: replacement,
|
||||
capturedTransport: original,
|
||||
capturedPtyId: 'pty-original',
|
||||
data: '\r'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(original.sendInput).not.toHaveBeenCalled()
|
||||
expect(replacement.sendInput).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not deliver after the captured transport rebinds to another PTY', () => {
|
||||
const transport = createTransport('pty-replacement')
|
||||
|
||||
expect(
|
||||
sendCapturedTerminalInput({
|
||||
targetPaneMounted: true,
|
||||
currentTransport: transport,
|
||||
capturedTransport: transport,
|
||||
capturedPtyId: 'pty-original',
|
||||
data: '\r'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(transport.sendInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not deliver after pane disposal', () => {
|
||||
const transport = createTransport('pty-original')
|
||||
|
||||
expect(
|
||||
sendCapturedTerminalInput({
|
||||
targetPaneMounted: false,
|
||||
currentTransport: undefined,
|
||||
capturedTransport: transport,
|
||||
capturedPtyId: 'pty-original',
|
||||
data: '\r'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(transport.sendInput).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('requestCapturedTerminalReconfirmation', () => {
|
||||
it('reconfirms only through the still-current captured binding', () => {
|
||||
const requestDroidReconfirmation = vi.fn()
|
||||
const binding = { requestDroidReconfirmation }
|
||||
|
||||
requestCapturedTerminalReconfirmation(binding, binding)
|
||||
|
||||
expect(requestDroidReconfirmation).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not call a disposed binding or its replacement', () => {
|
||||
const original = { requestDroidReconfirmation: vi.fn() }
|
||||
const replacement = { requestDroidReconfirmation: vi.fn() }
|
||||
|
||||
requestCapturedTerminalReconfirmation(replacement, original)
|
||||
|
||||
expect(original.requestDroidReconfirmation).not.toHaveBeenCalled()
|
||||
expect(replacement.requestDroidReconfirmation).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import type { PtyTransport } from './pty-transport'
|
||||
|
||||
type CapturedTerminalInputDispatch = {
|
||||
targetPaneMounted: boolean
|
||||
currentTransport: PtyTransport | undefined
|
||||
capturedTransport: PtyTransport | undefined
|
||||
capturedPtyId: string | null
|
||||
data: string
|
||||
}
|
||||
|
||||
export type TerminalReconfirmationBinding = {
|
||||
requestDroidReconfirmation?: () => void
|
||||
}
|
||||
|
||||
export function sendCapturedTerminalInput({
|
||||
targetPaneMounted,
|
||||
currentTransport,
|
||||
capturedTransport,
|
||||
capturedPtyId,
|
||||
data
|
||||
}: CapturedTerminalInputDispatch): boolean {
|
||||
if (
|
||||
!targetPaneMounted ||
|
||||
!capturedTransport ||
|
||||
capturedPtyId === null ||
|
||||
currentTransport !== capturedTransport ||
|
||||
capturedTransport.getPtyId() !== capturedPtyId
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return capturedTransport.sendInput(data)
|
||||
}
|
||||
|
||||
export function requestCapturedTerminalReconfirmation(
|
||||
currentBinding: object | undefined,
|
||||
capturedBinding: TerminalReconfirmationBinding | undefined
|
||||
): void {
|
||||
if (currentBinding === capturedBinding) {
|
||||
capturedBinding?.requestDroidReconfirmation?.()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { PtyTransport } from './pty-transport'
|
||||
import {
|
||||
hasPendingTerminalImeComposition,
|
||||
installTerminalImeCompositionRoute,
|
||||
XTERM_COMPOSITION_SESSION_END_EVENT,
|
||||
XTERM_COMPOSITION_SESSION_START_EVENT
|
||||
} from './terminal-ime-composition-route'
|
||||
|
||||
function createTransport(ptyId: string | null): PtyTransport {
|
||||
return {
|
||||
getPtyId: vi.fn(() => ptyId)
|
||||
} as unknown as PtyTransport
|
||||
}
|
||||
|
||||
function sessionEvent(
|
||||
type: string,
|
||||
id: number,
|
||||
data?: string,
|
||||
dataPendingReconciliation = false
|
||||
): CustomEvent {
|
||||
return new CustomEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
detail: { id, data, dataPendingReconciliation }
|
||||
})
|
||||
}
|
||||
|
||||
function createHarness(ptyId = 'pty-original') {
|
||||
const element = document.createElement('div')
|
||||
const original = createTransport(ptyId)
|
||||
const state: {
|
||||
currentTransport: PtyTransport | undefined
|
||||
} = {
|
||||
currentTransport: original
|
||||
}
|
||||
const input = vi.fn()
|
||||
const route = installTerminalImeCompositionRoute({
|
||||
terminalElement: element,
|
||||
terminal: { input },
|
||||
capturedTransport: original,
|
||||
getCurrentTransport: () => state.currentTransport
|
||||
})
|
||||
const start = (id: number) =>
|
||||
element.dispatchEvent(sessionEvent(XTERM_COMPOSITION_SESSION_START_EVENT, id))
|
||||
const end = (id: number, data: string) =>
|
||||
element.dispatchEvent(sessionEvent(XTERM_COMPOSITION_SESSION_END_EVENT, id, data))
|
||||
return { element, original, state, input, route, start, end }
|
||||
}
|
||||
|
||||
describe('installTerminalImeCompositionRoute', () => {
|
||||
it('does not install on an uninitialized terminal element', () => {
|
||||
const transport = createTransport('pty-original')
|
||||
|
||||
const route = installTerminalImeCompositionRoute({
|
||||
terminalElement: {} as HTMLElement,
|
||||
terminal: { input: vi.fn() },
|
||||
capturedTransport: transport,
|
||||
getCurrentTransport: () => transport
|
||||
})
|
||||
|
||||
expect(() => route.dispose()).not.toThrow()
|
||||
})
|
||||
|
||||
it.each(['visible blur', 'terminal tab switch', 'split pane switch'])(
|
||||
'delivers one Hangul commit to the captured route after %s',
|
||||
() => {
|
||||
const harness = createHarness()
|
||||
harness.start(1)
|
||||
|
||||
expect(harness.end(1, '한')).toBe(false)
|
||||
expect(harness.input).toHaveBeenCalledExactlyOnceWith('한')
|
||||
}
|
||||
)
|
||||
|
||||
it('never routes a switched composition to an unrelated transport', () => {
|
||||
const harness = createHarness()
|
||||
harness.start(1)
|
||||
harness.state.currentTransport = createTransport('pty-unrelated')
|
||||
|
||||
harness.end(1, '한')
|
||||
|
||||
expect(harness.input).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('finalizes empty cancellation without leaking into the next session', () => {
|
||||
const harness = createHarness()
|
||||
harness.start(1)
|
||||
harness.end(1, '')
|
||||
harness.start(2)
|
||||
harness.end(1, '한')
|
||||
harness.end(2, '글')
|
||||
|
||||
expect(harness.input).toHaveBeenCalledExactlyOnceWith('글')
|
||||
})
|
||||
|
||||
it('ignores duplicate and late composition completion', () => {
|
||||
const harness = createHarness()
|
||||
harness.start(1)
|
||||
harness.end(1, '한')
|
||||
harness.end(1, '한')
|
||||
|
||||
expect(harness.input).toHaveBeenCalledExactlyOnceWith('한')
|
||||
})
|
||||
|
||||
it('settles a session without forwarding bytes still pending xterm reconciliation', () => {
|
||||
const harness = createHarness()
|
||||
harness.start(1)
|
||||
|
||||
harness.element.dispatchEvent(sessionEvent(XTERM_COMPOSITION_SESSION_END_EVENT, 1, '앙', true))
|
||||
|
||||
expect(harness.input).not.toHaveBeenCalled()
|
||||
expect(hasPendingTerminalImeComposition(harness.element)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps every captured session until its delayed completion', () => {
|
||||
const harness = createHarness()
|
||||
harness.start(1)
|
||||
harness.start(2)
|
||||
harness.start(3)
|
||||
expect(hasPendingTerminalImeComposition(harness.element)).toBe(true)
|
||||
harness.end(1, '안')
|
||||
harness.end(2, '녕')
|
||||
expect(hasPendingTerminalImeComposition(harness.element)).toBe(true)
|
||||
harness.end(3, '하')
|
||||
|
||||
expect(harness.input.mock.calls).toEqual([['안'], ['녕'], ['하']])
|
||||
expect(hasPendingTerminalImeComposition(harness.element)).toBe(false)
|
||||
})
|
||||
|
||||
it('drops a commit after same-pane PTY replacement', () => {
|
||||
const harness = createHarness()
|
||||
harness.start(1)
|
||||
vi.mocked(harness.original.getPtyId).mockReturnValue('pty-replacement')
|
||||
|
||||
harness.end(1, '한')
|
||||
|
||||
expect(harness.input).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops a commit after SSH reconnect replaces the transport', () => {
|
||||
const harness = createHarness('remote:env:pty-original')
|
||||
harness.start(1)
|
||||
harness.state.currentTransport = createTransport('remote:env:pty-replacement')
|
||||
|
||||
harness.end(1, '한')
|
||||
|
||||
expect(harness.input).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['terminal close', 'tab unmount'])(
|
||||
'releases the captured session and listeners on %s',
|
||||
() => {
|
||||
const harness = createHarness()
|
||||
harness.start(1)
|
||||
harness.route.dispose()
|
||||
|
||||
expect(harness.end(1, '한')).toBe(true)
|
||||
expect(harness.input).not.toHaveBeenCalled()
|
||||
expect(hasPendingTerminalImeComposition(harness.element)).toBe(false)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import type { IDisposable, Terminal } from '@xterm/xterm'
|
||||
import type { PtyTransport } from './pty-transport'
|
||||
|
||||
export const XTERM_COMPOSITION_SESSION_START_EVENT = 'xterm-composition-session-start'
|
||||
export const XTERM_COMPOSITION_SESSION_END_EVENT = 'xterm-composition-session-end'
|
||||
|
||||
type CompositionSessionDetail = {
|
||||
id: number
|
||||
data?: string
|
||||
dataPendingReconciliation?: boolean
|
||||
}
|
||||
|
||||
type CapturedCompositionSession = {
|
||||
ptyId: string | null
|
||||
}
|
||||
|
||||
const pendingCompositionCountByElement = new WeakMap<HTMLElement, number>()
|
||||
|
||||
function adjustPendingCompositionCount(terminalElement: HTMLElement, delta: number): void {
|
||||
const count = Math.max(0, (pendingCompositionCountByElement.get(terminalElement) ?? 0) + delta)
|
||||
if (count === 0) {
|
||||
pendingCompositionCountByElement.delete(terminalElement)
|
||||
return
|
||||
}
|
||||
pendingCompositionCountByElement.set(terminalElement, count)
|
||||
}
|
||||
|
||||
export function hasPendingTerminalImeComposition(
|
||||
terminalElement: HTMLElement | null | undefined
|
||||
): boolean {
|
||||
return Boolean(terminalElement && pendingCompositionCountByElement.has(terminalElement))
|
||||
}
|
||||
|
||||
function getCompositionDetail(event: Event): CompositionSessionDetail | null {
|
||||
if (!(event instanceof CustomEvent)) {
|
||||
return null
|
||||
}
|
||||
const detail = event.detail as Partial<CompositionSessionDetail> | null
|
||||
if (!detail || !Number.isSafeInteger(detail.id) || detail.id! <= 0) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
id: detail.id!,
|
||||
data: typeof detail.data === 'string' ? detail.data : undefined,
|
||||
dataPendingReconciliation: detail.dataPendingReconciliation === true
|
||||
}
|
||||
}
|
||||
|
||||
export function installTerminalImeCompositionRoute(args: {
|
||||
terminalElement: HTMLElement | null | undefined
|
||||
terminal: Pick<Terminal, 'input'>
|
||||
capturedTransport: PtyTransport
|
||||
getCurrentTransport: () => PtyTransport | undefined
|
||||
}): IDisposable {
|
||||
const terminalElement = args.terminalElement
|
||||
const sessions = new Map<number, CapturedCompositionSession>()
|
||||
let disposed = false
|
||||
|
||||
if (
|
||||
!terminalElement ||
|
||||
typeof terminalElement.addEventListener !== 'function' ||
|
||||
typeof terminalElement.removeEventListener !== 'function'
|
||||
) {
|
||||
return { dispose: () => undefined }
|
||||
}
|
||||
|
||||
const onSessionStart = (event: Event): void => {
|
||||
const detail = getCompositionDetail(event)
|
||||
if (!detail || disposed) {
|
||||
return
|
||||
}
|
||||
if (!sessions.has(detail.id)) {
|
||||
adjustPendingCompositionCount(terminalElement, 1)
|
||||
}
|
||||
sessions.set(detail.id, {
|
||||
ptyId: args.capturedTransport.getPtyId()
|
||||
})
|
||||
}
|
||||
|
||||
const onSessionEnd = (event: Event): void => {
|
||||
const detail = getCompositionDetail(event)
|
||||
if (!detail) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
const captured = sessions.get(detail.id)
|
||||
if (!captured) {
|
||||
return
|
||||
}
|
||||
sessions.delete(detail.id)
|
||||
adjustPendingCompositionCount(terminalElement, -1)
|
||||
if (
|
||||
disposed ||
|
||||
detail.dataPendingReconciliation ||
|
||||
!detail.data ||
|
||||
captured.ptyId === null ||
|
||||
args.getCurrentTransport() !== args.capturedTransport ||
|
||||
args.capturedTransport.getPtyId() !== captured.ptyId
|
||||
) {
|
||||
return
|
||||
}
|
||||
args.terminal.input(detail.data)
|
||||
}
|
||||
|
||||
terminalElement.addEventListener(XTERM_COMPOSITION_SESSION_START_EVENT, onSessionStart)
|
||||
terminalElement.addEventListener(XTERM_COMPOSITION_SESSION_END_EVENT, onSessionEnd)
|
||||
|
||||
return {
|
||||
dispose: () => {
|
||||
disposed = true
|
||||
adjustPendingCompositionCount(terminalElement, -sessions.size)
|
||||
sessions.clear()
|
||||
terminalElement.removeEventListener(XTERM_COMPOSITION_SESSION_START_EVENT, onSessionStart)
|
||||
terminalElement.removeEventListener(XTERM_COMPOSITION_SESSION_END_EVENT, onSessionEnd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
installTerminalImeNativeTextForwarder,
|
||||
XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT,
|
||||
XTERM_COMPOSITION_TRANSACTION_SETTLED_EVENT
|
||||
} from './terminal-ime-native-text-forwarder'
|
||||
|
||||
function wonKeydown(): {
|
||||
type: 'keydown'
|
||||
key: string
|
||||
code: string
|
||||
metaKey: false
|
||||
ctrlKey: false
|
||||
altKey: false
|
||||
isComposing: false
|
||||
keyCode: number
|
||||
} {
|
||||
return {
|
||||
type: 'keydown',
|
||||
key: '₩',
|
||||
code: 'Backquote',
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
isComposing: false,
|
||||
keyCode: 192
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchInsertText(target: HTMLElement, data: '`' | '₩' = '`'): void {
|
||||
target.dispatchEvent(new InputEvent('input', { data, inputType: 'insertText', bubbles: true }))
|
||||
}
|
||||
|
||||
describe('xterm composition transaction ownership', () => {
|
||||
let element: HTMLDivElement
|
||||
let textarea: HTMLTextAreaElement
|
||||
|
||||
beforeEach(() => {
|
||||
element = document.createElement('div')
|
||||
textarea = document.createElement('textarea')
|
||||
element.appendChild(textarea)
|
||||
document.body.replaceChildren(element)
|
||||
})
|
||||
|
||||
it('leaves every immediate native remap with an accepted composition transaction', () => {
|
||||
const sendInput = vi.fn()
|
||||
const downstream = vi.fn()
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: element,
|
||||
isComposing: () => false,
|
||||
sendInput
|
||||
})
|
||||
element.addEventListener('input', downstream, true)
|
||||
|
||||
textarea.dispatchEvent(
|
||||
new CustomEvent(XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT, { bubbles: true })
|
||||
)
|
||||
for (const [value, data] of [
|
||||
['한`', '`'],
|
||||
['한`₩', '₩']
|
||||
] as const) {
|
||||
expect(forwarder.claimKeyEvent(wonKeydown())).toBe(true)
|
||||
textarea.value = value
|
||||
dispatchInsertText(textarea, data)
|
||||
}
|
||||
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
expect(downstream).not.toHaveBeenCalled()
|
||||
expect(textarea.value).toBe('한`₩')
|
||||
|
||||
textarea.dispatchEvent(
|
||||
new CustomEvent(XTERM_COMPOSITION_TRANSACTION_SETTLED_EVENT, { bubbles: true })
|
||||
)
|
||||
expect(forwarder.claimKeyEvent(wonKeydown())).toBe(true)
|
||||
textarea.value = '`'
|
||||
dispatchInsertText(textarea)
|
||||
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith('`')
|
||||
expect(textarea.value).toBe('')
|
||||
})
|
||||
|
||||
it('does not let repeated rejected composition ends steal an immediate remap', () => {
|
||||
const sendInput = vi.fn()
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: element,
|
||||
isComposing: () => false,
|
||||
sendInput
|
||||
})
|
||||
|
||||
for (let index = 0; index < 3; index++) {
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }))
|
||||
}
|
||||
expect(forwarder.claimKeyEvent(wonKeydown())).toBe(true)
|
||||
dispatchInsertText(textarea)
|
||||
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith('`')
|
||||
})
|
||||
|
||||
it('restarts ownership when another composition transaction is accepted', () => {
|
||||
const sendInput = vi.fn()
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: element,
|
||||
isComposing: () => false,
|
||||
sendInput
|
||||
})
|
||||
|
||||
for (let index = 0; index < 2; index++) {
|
||||
textarea.dispatchEvent(
|
||||
new CustomEvent(XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT, { bubbles: true })
|
||||
)
|
||||
expect(forwarder.claimKeyEvent(wonKeydown())).toBe(true)
|
||||
dispatchInsertText(textarea)
|
||||
}
|
||||
textarea.dispatchEvent(
|
||||
new CustomEvent(XTERM_COMPOSITION_TRANSACTION_SETTLED_EVENT, { bubbles: true })
|
||||
)
|
||||
expect(forwarder.claimKeyEvent(wonKeydown())).toBe(true)
|
||||
dispatchInsertText(textarea)
|
||||
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith('`')
|
||||
})
|
||||
|
||||
it('drops composition ownership on blur and unmount', () => {
|
||||
const sendInput = vi.fn()
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: element,
|
||||
isComposing: () => false,
|
||||
sendInput
|
||||
})
|
||||
|
||||
textarea.dispatchEvent(
|
||||
new CustomEvent(XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT, { bubbles: true })
|
||||
)
|
||||
expect(forwarder.claimKeyEvent(wonKeydown())).toBe(true)
|
||||
textarea.dispatchEvent(new FocusEvent('blur'))
|
||||
expect(forwarder.claimKeyEvent(wonKeydown())).toBe(true)
|
||||
dispatchInsertText(textarea)
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith('`')
|
||||
|
||||
textarea.dispatchEvent(
|
||||
new CustomEvent(XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT, { bubbles: true })
|
||||
)
|
||||
expect(forwarder.claimKeyEvent(wonKeydown())).toBe(true)
|
||||
element.remove()
|
||||
forwarder.dispose()
|
||||
dispatchInsertText(textarea)
|
||||
|
||||
expect(sendInput).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,365 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PtyTransport } from './pty-transport'
|
||||
import {
|
||||
createTerminalImeDeferredNewlineSender,
|
||||
createTerminalImeModifiedEnterChordOwner,
|
||||
isTerminalImeEnterKeyUp,
|
||||
isTerminalImeProcessEnter,
|
||||
sendTerminalInputAfterComposition
|
||||
} from './terminal-ime-deferred-newline'
|
||||
import {
|
||||
installTerminalImeCompositionRoute,
|
||||
XTERM_COMPOSITION_SESSION_END_EVENT,
|
||||
XTERM_COMPOSITION_SESSION_START_EVENT
|
||||
} from './terminal-ime-composition-route'
|
||||
|
||||
describe('sendTerminalInputAfterComposition', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('sends the newline one macrotask after compositionend so the glyph flushes first', () => {
|
||||
const el = document.createElement('div')
|
||||
const send = vi.fn()
|
||||
|
||||
sendTerminalInputAfterComposition(el, send)
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
|
||||
el.dispatchEvent(new Event('compositionend'))
|
||||
// Deferred a macrotask so xterm's own post-compositionend flush runs first.
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
|
||||
vi.runAllTimers()
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('falls back to sending when no compositionend arrives', () => {
|
||||
const el = document.createElement('div')
|
||||
const send = vi.fn()
|
||||
|
||||
sendTerminalInputAfterComposition(el, send)
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('finishes from the captured xterm transaction when deferral starts after compositionend', () => {
|
||||
const el = document.createElement('div')
|
||||
const send = vi.fn()
|
||||
|
||||
sendTerminalInputAfterComposition(el, send)
|
||||
el.dispatchEvent(new CustomEvent(XTERM_COMPOSITION_SESSION_END_EVENT))
|
||||
vi.advanceTimersByTime(0)
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('waits for every overlapping captured xterm transaction', () => {
|
||||
const el = document.createElement('div')
|
||||
const send = vi.fn()
|
||||
const terminal = { input: vi.fn() }
|
||||
const transport = {
|
||||
getPtyId: () => 'pty-1'
|
||||
} as unknown as PtyTransport
|
||||
const route = installTerminalImeCompositionRoute({
|
||||
terminalElement: el,
|
||||
terminal,
|
||||
capturedTransport: transport,
|
||||
getCurrentTransport: () => transport
|
||||
})
|
||||
const sessionEvent = (type: string, id: number) =>
|
||||
new CustomEvent(type, { detail: { id, data: `commit-${id}` } })
|
||||
|
||||
el.dispatchEvent(sessionEvent(XTERM_COMPOSITION_SESSION_START_EVENT, 1))
|
||||
el.dispatchEvent(sessionEvent(XTERM_COMPOSITION_SESSION_START_EVENT, 2))
|
||||
sendTerminalInputAfterComposition(el, send)
|
||||
el.dispatchEvent(sessionEvent(XTERM_COMPOSITION_SESSION_END_EVENT, 1))
|
||||
vi.advanceTimersByTime(0)
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
|
||||
el.dispatchEvent(sessionEvent(XTERM_COMPOSITION_SESSION_END_EVENT, 2))
|
||||
vi.advanceTimersByTime(0)
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
|
||||
route.dispose()
|
||||
})
|
||||
|
||||
it('sends only once and drops the listener after firing', () => {
|
||||
const el = document.createElement('div')
|
||||
const send = vi.fn()
|
||||
|
||||
sendTerminalInputAfterComposition(el, send)
|
||||
el.dispatchEvent(new Event('compositionend'))
|
||||
vi.runAllTimers()
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
|
||||
// A later composition on the same terminal must not re-fire the stale newline.
|
||||
el.dispatchEvent(new Event('compositionend'))
|
||||
vi.runAllTimers()
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not double-send when compositionend arrives after the fallback fired', () => {
|
||||
const el = document.createElement('div')
|
||||
const send = vi.fn()
|
||||
|
||||
sendTerminalInputAfterComposition(el, send)
|
||||
vi.runAllTimers()
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
|
||||
el.dispatchEvent(new Event('compositionend'))
|
||||
vi.runAllTimers()
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('still delivers the input on the next macrotask without a terminal element', () => {
|
||||
const send = vi.fn()
|
||||
|
||||
sendTerminalInputAfterComposition(null, send)
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
|
||||
vi.runAllTimers()
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createTerminalImeDeferredNewlineSender', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const createSender = () => createTerminalImeDeferredNewlineSender()
|
||||
const enter = (timeStamp: number, code = 'Enter') => ({ code, timeStamp })
|
||||
|
||||
it('absorbs the re-dispatch while the deferred send is still in flight, exactly once', () => {
|
||||
const el = document.createElement('div')
|
||||
const send = vi.fn()
|
||||
const sender = createSender()
|
||||
|
||||
sender.defer(enter(10), el, send)
|
||||
expect(sender.absorbRedispatchedEnter(enter(10))).toBe(true)
|
||||
expect(sender.absorbRedispatchedEnter(enter(10))).toBe(false)
|
||||
|
||||
el.dispatchEvent(new Event('compositionend'))
|
||||
vi.runAllTimers()
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
// The credit was consumed pre-send, so nothing lingers to eat a real Enter.
|
||||
expect(sender.absorbRedispatchedEnter(enter(10))).toBe(false)
|
||||
})
|
||||
|
||||
it('absorbs after the deferred send even if focus moved to another pane', () => {
|
||||
const el = document.createElement('div')
|
||||
const send = vi.fn()
|
||||
const sender = createSender()
|
||||
|
||||
sender.defer(enter(10), el, send)
|
||||
el.dispatchEvent(new Event('compositionend'))
|
||||
vi.runAllTimers()
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(sender.absorbRedispatchedEnter(enter(10))).toBe(true)
|
||||
expect(sender.absorbRedispatchedEnter(enter(10))).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a credit across the balancing keyup copied from the same native event', () => {
|
||||
const el = document.createElement('div')
|
||||
const sender = createSender()
|
||||
|
||||
sender.defer(enter(10), el, vi.fn())
|
||||
el.dispatchEvent(new Event('compositionend'))
|
||||
vi.runAllTimers()
|
||||
|
||||
sender.releaseRedispatchedEnter(enter(10))
|
||||
expect(sender.absorbRedispatchedEnter(enter(10))).toBe(true)
|
||||
})
|
||||
|
||||
it('releases an unused credit on a later physical keyup', () => {
|
||||
const el = document.createElement('div')
|
||||
const sender = createSender()
|
||||
|
||||
sender.defer(enter(10), el, vi.fn())
|
||||
sender.releaseRedispatchedEnter(enter(11))
|
||||
el.dispatchEvent(new Event('compositionend'))
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(sender.absorbRedispatchedEnter(enter(10))).toBe(false)
|
||||
})
|
||||
|
||||
it('retires stale credit when a genuinely new Enter begins without a redispatch', () => {
|
||||
const el = document.createElement('div')
|
||||
const sender = createSender()
|
||||
|
||||
sender.defer(enter(10), el, vi.fn())
|
||||
|
||||
expect(sender.absorbRedispatchedEnter(enter(20))).toBe(false)
|
||||
expect(sender.absorbRedispatchedEnter(enter(10))).toBe(false)
|
||||
})
|
||||
|
||||
it('absorbs a matching repeated composition cycle but not a new plain repeat', () => {
|
||||
const el = document.createElement('div')
|
||||
const sender = createSender()
|
||||
|
||||
sender.defer(enter(10), el, vi.fn())
|
||||
expect(sender.absorbRedispatchedEnter(enter(20))).toBe(false)
|
||||
|
||||
sender.defer(enter(30), el, vi.fn())
|
||||
expect(sender.absorbRedispatchedEnter(enter(30))).toBe(true)
|
||||
})
|
||||
|
||||
it('tracks the main and numpad Enter keys independently', () => {
|
||||
const el = document.createElement('div')
|
||||
const sender = createSender()
|
||||
|
||||
sender.defer(enter(10), el, vi.fn())
|
||||
expect(sender.absorbRedispatchedEnter(enter(10, 'NumpadEnter'))).toBe(false)
|
||||
expect(sender.absorbRedispatchedEnter(enter(10))).toBe(true)
|
||||
})
|
||||
|
||||
it('tracks two overlapping Enter cycles independently by native timestamp', () => {
|
||||
const el = document.createElement('div')
|
||||
const sender = createSender()
|
||||
|
||||
sender.defer(enter(10), el, vi.fn())
|
||||
sender.defer(enter(20), el, vi.fn())
|
||||
expect(sender.absorbRedispatchedEnter(enter(20))).toBe(true)
|
||||
expect(sender.absorbRedispatchedEnter(enter(10))).toBe(true)
|
||||
expect(sender.absorbRedispatchedEnter(enter(20))).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a re-dispatch credit on the fallback path', () => {
|
||||
const el = document.createElement('div')
|
||||
const send = vi.fn()
|
||||
const sender = createSender()
|
||||
|
||||
sender.defer(enter(10), el, send)
|
||||
vi.runAllTimers()
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(sender.absorbRedispatchedEnter(enter(10))).toBe(true)
|
||||
})
|
||||
|
||||
it('still delivers without a terminal element and keeps one credit', () => {
|
||||
const send = vi.fn()
|
||||
const sender = createSender()
|
||||
|
||||
sender.defer(enter(10), null, send)
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
expect(sender.absorbRedispatchedEnter(enter(10))).toBe(true)
|
||||
expect(sender.absorbRedispatchedEnter(enter(10))).toBe(false)
|
||||
})
|
||||
|
||||
it('clears credits after a missed keyup when the window blurs', () => {
|
||||
const el = document.createElement('div')
|
||||
const sender = createSender()
|
||||
|
||||
sender.defer(enter(10), el, vi.fn())
|
||||
el.dispatchEvent(new Event('compositionend'))
|
||||
vi.runAllTimers()
|
||||
|
||||
sender.clearRedispatchedEnters()
|
||||
expect(sender.absorbRedispatchedEnter(enter(10))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createTerminalImeModifiedEnterChordOwner', () => {
|
||||
const chord = (kind: 'shift' | 'ctrl', timeStamp: number, code = '') => ({
|
||||
kind,
|
||||
code,
|
||||
timeStamp
|
||||
})
|
||||
|
||||
it('owns one Windows Process sequence across changing timestamps and blank codes', () => {
|
||||
const owner = createTerminalImeModifiedEnterChordOwner()
|
||||
const defer = vi.fn()
|
||||
|
||||
for (const event of [chord('shift', 5035.8), chord('shift', 5036.9)]) {
|
||||
if (owner.claim(event)) {
|
||||
defer()
|
||||
}
|
||||
}
|
||||
|
||||
expect(defer).toHaveBeenCalledTimes(1)
|
||||
expect(owner.absorb(chord('shift', 5037.9, 'Enter'))).toBe(true)
|
||||
})
|
||||
|
||||
it('does not merge different modified Enter kinds into one chord', () => {
|
||||
const owner = createTerminalImeModifiedEnterChordOwner()
|
||||
|
||||
expect(owner.claim(chord('shift', 10, 'Enter'))).toBe(true)
|
||||
expect(owner.claim(chord('ctrl', 11, 'Enter'))).toBe(false)
|
||||
expect(owner.absorb(chord('ctrl', 12, 'Enter'))).toBe(false)
|
||||
})
|
||||
|
||||
it('releases at the physical key boundary so the next Enter is not consumed', () => {
|
||||
const owner = createTerminalImeModifiedEnterChordOwner()
|
||||
|
||||
expect(owner.claim(chord('ctrl', 10))).toBe(true)
|
||||
owner.release(chord('ctrl', 30, 'Enter'))
|
||||
|
||||
expect(owner.absorb(chord('ctrl', 40, 'Enter'))).toBe(false)
|
||||
expect(owner.claim(chord('ctrl', 40, 'Enter'))).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores a mismatched release and clears lost keyup state explicitly', () => {
|
||||
const owner = createTerminalImeModifiedEnterChordOwner()
|
||||
|
||||
expect(owner.claim(chord('shift', 10))).toBe(true)
|
||||
owner.release(chord('ctrl', 20))
|
||||
expect(owner.absorb(chord('shift', 30))).toBe(true)
|
||||
owner.clear()
|
||||
|
||||
expect(owner.absorb(chord('shift', 40))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isTerminalImeProcessEnter', () => {
|
||||
const event = (overrides: Partial<KeyboardEvent> = {}) =>
|
||||
({
|
||||
key: 'Process',
|
||||
keyCode: 229,
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
shiftKey: true,
|
||||
...overrides
|
||||
}) as KeyboardEvent
|
||||
|
||||
it.each([{ shiftKey: true }, { shiftKey: false, ctrlKey: true }])(
|
||||
'recognizes a Windows IME modifier Enter reported as Process',
|
||||
(modifiers) => {
|
||||
expect(isTerminalImeProcessEnter(event(modifiers))).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
{ key: 'Enter' },
|
||||
{ keyCode: 13 },
|
||||
{ shiftKey: false },
|
||||
{ ctrlKey: true },
|
||||
{ altKey: true }
|
||||
])('rejects a non-IME or ambiguous Process key', (override) => {
|
||||
expect(isTerminalImeProcessEnter(event(override))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isTerminalImeEnterKeyUp', () => {
|
||||
it('recognizes the balancing Enter keyup when Chromium drops its modifier', () => {
|
||||
expect(isTerminalImeEnterKeyUp({ key: 'Enter', keyCode: 13 })).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ key: 'Process', keyCode: 13 },
|
||||
{ key: 'Enter', keyCode: 229 }
|
||||
])('rejects a non-Enter balancing event', (event) => {
|
||||
expect(isTerminalImeEnterKeyUp(event)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
import {
|
||||
hasPendingTerminalImeComposition,
|
||||
XTERM_COMPOSITION_SESSION_END_EVENT
|
||||
} from './terminal-ime-composition-route'
|
||||
|
||||
export const TERMINAL_IME_DEFERRED_NEWLINE_FALLBACK_MS = 200
|
||||
|
||||
export function sendTerminalInputAfterComposition(
|
||||
terminalElement: HTMLElement | null | undefined,
|
||||
send: () => void,
|
||||
options?: { fallbackMs?: number }
|
||||
): void {
|
||||
if (!terminalElement) {
|
||||
window.setTimeout(send, 0)
|
||||
return
|
||||
}
|
||||
|
||||
const fallbackMs = options?.fallbackMs ?? TERMINAL_IME_DEFERRED_NEWLINE_FALLBACK_MS
|
||||
let done = false
|
||||
|
||||
const finish = (): void => {
|
||||
if (done) {
|
||||
return
|
||||
}
|
||||
done = true
|
||||
terminalElement.removeEventListener('compositionend', onCompositionEnd)
|
||||
terminalElement.removeEventListener(
|
||||
XTERM_COMPOSITION_SESSION_END_EVENT,
|
||||
onCompositionSessionEnd
|
||||
)
|
||||
window.clearTimeout(fallbackTimer)
|
||||
// xterm flushes the committed glyph after compositionend.
|
||||
window.setTimeout(send, 0)
|
||||
}
|
||||
|
||||
const finishAfterPendingComposition = (): void => {
|
||||
if (!hasPendingTerminalImeComposition(terminalElement)) {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
const onCompositionEnd = (): void => finishAfterPendingComposition()
|
||||
const onCompositionSessionEnd = (): void => finishAfterPendingComposition()
|
||||
terminalElement.addEventListener('compositionend', onCompositionEnd)
|
||||
terminalElement.addEventListener(XTERM_COMPOSITION_SESSION_END_EVENT, onCompositionSessionEnd)
|
||||
const fallbackTimer = window.setTimeout(finish, fallbackMs)
|
||||
}
|
||||
|
||||
export type TerminalImeDeferredNewlineSender = {
|
||||
defer: (
|
||||
enter: TerminalImeEnterIdentity,
|
||||
terminalElement: HTMLElement | null | undefined,
|
||||
send: () => void
|
||||
) => void
|
||||
absorbRedispatchedEnter: (enter: TerminalImeEnterIdentity) => boolean
|
||||
releaseRedispatchedEnter: (enter: TerminalImeEnterIdentity) => void
|
||||
clearRedispatchedEnters: () => void
|
||||
}
|
||||
|
||||
export type TerminalImeEnterIdentity = Pick<KeyboardEvent, 'code' | 'timeStamp'>
|
||||
|
||||
export type TerminalImeModifiedEnterKind = 'shift' | 'ctrl'
|
||||
|
||||
export type TerminalImeModifiedEnterChord = TerminalImeEnterIdentity & {
|
||||
kind: TerminalImeModifiedEnterKind
|
||||
}
|
||||
|
||||
export type TerminalImeModifiedEnterChordOwner = {
|
||||
claim: (chord: TerminalImeModifiedEnterChord) => boolean
|
||||
absorb: (chord: TerminalImeModifiedEnterChord) => boolean
|
||||
release: (chord: TerminalImeModifiedEnterChord) => void
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
export function createTerminalImeModifiedEnterChordOwner(): TerminalImeModifiedEnterChordOwner {
|
||||
let activeKind: TerminalImeModifiedEnterKind | null = null
|
||||
|
||||
return {
|
||||
claim: ({ kind }) => {
|
||||
if (activeKind !== null) {
|
||||
return false
|
||||
}
|
||||
activeKind = kind
|
||||
return true
|
||||
},
|
||||
absorb: ({ kind }) => activeKind === kind,
|
||||
release: ({ kind }) => {
|
||||
if (activeKind === kind) {
|
||||
activeKind = null
|
||||
}
|
||||
},
|
||||
clear: () => {
|
||||
activeKind = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getTerminalImeModifiedEnterKind(
|
||||
event: Pick<KeyboardEvent, 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'>
|
||||
): TerminalImeModifiedEnterKind | null {
|
||||
if (event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||
return 'shift'
|
||||
}
|
||||
if (event.ctrlKey && !event.shiftKey && !event.metaKey && !event.altKey) {
|
||||
return 'ctrl'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function isTerminalImeProcessEnter(
|
||||
event: Pick<KeyboardEvent, 'key' | 'keyCode' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'>
|
||||
): boolean {
|
||||
return (
|
||||
event.key === 'Process' &&
|
||||
event.keyCode === 229 &&
|
||||
getTerminalImeModifiedEnterKind(event) !== null
|
||||
)
|
||||
}
|
||||
|
||||
export function isTerminalImeEnterKeyUp(event: Pick<KeyboardEvent, 'key' | 'keyCode'>): boolean {
|
||||
return event.key === 'Enter' && event.keyCode === 13
|
||||
}
|
||||
|
||||
type DeferredNewlineState = {
|
||||
inFlightSends: number
|
||||
absorbCredits: number
|
||||
}
|
||||
|
||||
export function createTerminalImeDeferredNewlineSender(): TerminalImeDeferredNewlineSender {
|
||||
const statesByEnterCode = new Map<string, Map<number, DeferredNewlineState>>()
|
||||
|
||||
const cleanUpIfSettled = (enter: TerminalImeEnterIdentity, state: DeferredNewlineState): void => {
|
||||
if (state.inFlightSends <= 0 && state.absorbCredits <= 0) {
|
||||
const statesByTimeStamp = statesByEnterCode.get(enter.code)
|
||||
statesByTimeStamp?.delete(enter.timeStamp)
|
||||
if (statesByTimeStamp?.size === 0) {
|
||||
statesByEnterCode.delete(enter.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const clearCreditsForCode = (enterCode: string): void => {
|
||||
const statesByTimeStamp = statesByEnterCode.get(enterCode)
|
||||
if (!statesByTimeStamp) {
|
||||
return
|
||||
}
|
||||
for (const [timeStamp, state] of statesByTimeStamp) {
|
||||
state.absorbCredits = 0
|
||||
cleanUpIfSettled({ code: enterCode, timeStamp }, state)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
defer: (enter, terminalElement, send) => {
|
||||
const statesByTimeStamp = statesByEnterCode.get(enter.code) ?? new Map()
|
||||
const state = statesByTimeStamp.get(enter.timeStamp) ?? {
|
||||
inFlightSends: 0,
|
||||
absorbCredits: 0
|
||||
}
|
||||
state.inFlightSends += 1
|
||||
state.absorbCredits += 1
|
||||
statesByTimeStamp.set(enter.timeStamp, state)
|
||||
statesByEnterCode.set(enter.code, statesByTimeStamp)
|
||||
sendTerminalInputAfterComposition(terminalElement, () => {
|
||||
state.inFlightSends -= 1
|
||||
cleanUpIfSettled(enter, state)
|
||||
send()
|
||||
})
|
||||
},
|
||||
absorbRedispatchedEnter: (enter) => {
|
||||
const state = statesByEnterCode.get(enter.code)?.get(enter.timeStamp)
|
||||
if (!state || state.absorbCredits <= 0) {
|
||||
clearCreditsForCode(enter.code)
|
||||
return false
|
||||
}
|
||||
state.absorbCredits -= 1
|
||||
cleanUpIfSettled(enter, state)
|
||||
return true
|
||||
},
|
||||
releaseRedispatchedEnter: (enter) => {
|
||||
if (statesByEnterCode.get(enter.code)?.has(enter.timeStamp)) {
|
||||
// Chromium's balancing Process-key keyup is copied from the same native event.
|
||||
return
|
||||
}
|
||||
clearCreditsForCode(enter.code)
|
||||
},
|
||||
clearRedispatchedEnters: () => {
|
||||
for (const enterCode of statesByEnterCode.keys()) {
|
||||
clearCreditsForCode(enterCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -41,6 +41,9 @@ const CJK_DIRECT_PUNCTUATION_KEYS = new Set<string>([
|
|||
'『',
|
||||
'』',
|
||||
'¥',
|
||||
// Korean sources put ₩ on Backquote; its committed text can differ from
|
||||
// `key` when DefaultKeyBinding.dict rewrites it (typically back to `).
|
||||
'₩',
|
||||
'~',
|
||||
'·',
|
||||
'…'
|
||||
|
|
|
|||
|
|
@ -68,6 +68,18 @@ describe('isImeNativeTextKeydownCandidate', () => {
|
|||
).toBe(true)
|
||||
})
|
||||
|
||||
// Why: macOS Korean sources emit ₩ from Backquote, and a DefaultKeyBinding.dict
|
||||
// entry can rewrite it to ` — but only in the keypress/input events.
|
||||
it('accepts the Korean won-sign key even when the input source probe is stale', () => {
|
||||
expect(
|
||||
isImeNativeTextKeydownCandidate(
|
||||
keyEvent({ key: '₩', code: 'Backquote', keyCode: 192 }),
|
||||
false,
|
||||
DISABLED_FEATURES
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts Vietnamese short replacement keys without enabling punctuation', () => {
|
||||
expect(
|
||||
isImeNativeTextKeydownCandidate(
|
||||
|
|
@ -264,6 +276,40 @@ describe('installTerminalImeNativeTextForwarder', () => {
|
|||
expect(sendInput).toHaveBeenCalledExactlyOnceWith('。')
|
||||
})
|
||||
|
||||
it('forwards the key binding substitution for the Korean won-sign key', () => {
|
||||
const sendInput = vi.fn()
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: element,
|
||||
isComposing: () => false,
|
||||
sendInput,
|
||||
getInputSourceFeatures: () => CJK_FEATURES
|
||||
})
|
||||
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: '₩', code: 'Backquote', keyCode: 192 }))).toBe(
|
||||
true
|
||||
)
|
||||
dispatchInsertText(textarea, '`')
|
||||
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith('`')
|
||||
})
|
||||
|
||||
it('forwards the won sign unchanged when no key binding remaps it', () => {
|
||||
const sendInput = vi.fn()
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: element,
|
||||
isComposing: () => false,
|
||||
sendInput,
|
||||
getInputSourceFeatures: () => CJK_FEATURES
|
||||
})
|
||||
|
||||
expect(forwarder.claimKeyEvent(keyEvent({ key: '₩', code: 'Backquote', keyCode: 192 }))).toBe(
|
||||
true
|
||||
)
|
||||
dispatchInsertText(textarea, '₩')
|
||||
|
||||
expect(sendInput).toHaveBeenCalledExactlyOnceWith('₩')
|
||||
})
|
||||
|
||||
it('forwards a plain ASCII symbol unchanged when the IME does not convert it', () => {
|
||||
const sendInput = vi.fn()
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ type ClaimedKeyPress = {
|
|||
code?: string
|
||||
}
|
||||
|
||||
export const XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT = 'xterm-composition-transaction-accepted'
|
||||
export const XTERM_COMPOSITION_TRANSACTION_SETTLED_EVENT = 'xterm-composition-transaction-settled'
|
||||
|
||||
export type TerminalImeNativeTextForwarder = IDisposable & {
|
||||
/**
|
||||
* Returns true when this keyboard event belongs to a direct native text
|
||||
|
|
@ -68,6 +71,7 @@ export function installTerminalImeNativeTextForwarder(args: {
|
|||
const terminalElement = args.terminalElement
|
||||
let pendingForward = false
|
||||
let pendingForwardClearTimer: number | null = null
|
||||
let compositionTransactionPending = false
|
||||
let claimedPress: ClaimedKeyPress | null = null
|
||||
|
||||
const clearPendingForwardTimer = (): void => {
|
||||
|
|
@ -92,6 +96,14 @@ export function installTerminalImeNativeTextForwarder(args: {
|
|||
}, 100)
|
||||
}
|
||||
|
||||
const markCompositionTransactionAccepted = (): void => {
|
||||
compositionTransactionPending = true
|
||||
}
|
||||
|
||||
const markCompositionTransactionSettled = (): void => {
|
||||
compositionTransactionPending = false
|
||||
}
|
||||
|
||||
const claimKeyEvent = (event: ImeNativeTextKeyEvent): boolean => {
|
||||
if (event.type === 'keydown') {
|
||||
if (
|
||||
|
|
@ -138,6 +150,11 @@ export function installTerminalImeNativeTextForwarder(args: {
|
|||
if (!(event instanceof InputEvent)) {
|
||||
return
|
||||
}
|
||||
if (compositionTransactionPending && event.inputType === 'insertText') {
|
||||
disarmPendingForward()
|
||||
event.stopImmediatePropagation()
|
||||
return
|
||||
}
|
||||
if (!pendingForward) {
|
||||
return
|
||||
}
|
||||
|
|
@ -159,9 +176,20 @@ export function installTerminalImeNativeTextForwarder(args: {
|
|||
|
||||
const cancelPending = (): void => {
|
||||
disarmPendingForward()
|
||||
compositionTransactionPending = false
|
||||
claimedPress = null
|
||||
}
|
||||
|
||||
terminalElement.addEventListener(
|
||||
XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT,
|
||||
markCompositionTransactionAccepted,
|
||||
true
|
||||
)
|
||||
terminalElement.addEventListener(
|
||||
XTERM_COMPOSITION_TRANSACTION_SETTLED_EVENT,
|
||||
markCompositionTransactionSettled,
|
||||
true
|
||||
)
|
||||
terminalElement.addEventListener('input', forwardCommittedText, true)
|
||||
terminalElement.addEventListener('blur', cancelPending, true)
|
||||
|
||||
|
|
@ -169,6 +197,16 @@ export function installTerminalImeNativeTextForwarder(args: {
|
|||
claimKeyEvent,
|
||||
dispose: () => {
|
||||
cancelPending()
|
||||
terminalElement.removeEventListener(
|
||||
XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT,
|
||||
markCompositionTransactionAccepted,
|
||||
true
|
||||
)
|
||||
terminalElement.removeEventListener(
|
||||
XTERM_COMPOSITION_TRANSACTION_SETTLED_EVENT,
|
||||
markCompositionTransactionSettled,
|
||||
true
|
||||
)
|
||||
terminalElement.removeEventListener('input', forwardCommittedText, true)
|
||||
terminalElement.removeEventListener('blur', cancelPending, true)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,189 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { installTerminalImeCompositionTracker } from './terminal-ime-composition-tracker'
|
||||
import { installTerminalImeNativeTextForwarder } from './terminal-ime-native-text-forwarder'
|
||||
|
||||
function nextEventLoop(): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function compositionEvent(type: string, data = ''): CompositionEvent {
|
||||
const event = new CompositionEvent(type, { data, bubbles: true })
|
||||
Object.defineProperty(event, 'data', { value: data })
|
||||
return event
|
||||
}
|
||||
|
||||
function keyboardEvent(
|
||||
type: 'keydown' | 'keypress' | 'keyup',
|
||||
key: string,
|
||||
code = 'Backquote',
|
||||
keyCode = 192
|
||||
): KeyboardEvent {
|
||||
const event = new KeyboardEvent(type, { key, code, bubbles: true })
|
||||
Object.defineProperties(event, {
|
||||
charCode: { value: type === 'keypress' ? key.charCodeAt(0) : 0 },
|
||||
keyCode: { value: keyCode },
|
||||
which: { value: type === 'keypress' ? key.charCodeAt(0) : keyCode }
|
||||
})
|
||||
return event
|
||||
}
|
||||
|
||||
async function typeHangulThenWon(
|
||||
committedWonTexts: readonly ('`' | '₩')[],
|
||||
options: {
|
||||
beforeWon?: (textarea: HTMLTextAreaElement) => void
|
||||
duplicateFirstInput?: boolean
|
||||
} = {}
|
||||
): Promise<string> {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const terminal = new Terminal()
|
||||
terminal.open(container)
|
||||
const textarea = terminal.textarea
|
||||
const terminalElement = terminal.element
|
||||
if (!textarea || !terminalElement) {
|
||||
throw new Error('xterm input elements were not created')
|
||||
}
|
||||
|
||||
const tracker = installTerminalImeCompositionTracker(terminalElement)
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement,
|
||||
isComposing: tracker.isActive,
|
||||
sendInput: (data) => terminal.input(data)
|
||||
})
|
||||
terminal.attachCustomKeyEventHandler((event) => !forwarder.claimKeyEvent(event))
|
||||
|
||||
const emitted: string[] = []
|
||||
terminal.onData((data) => emitted.push(data))
|
||||
textarea.dispatchEvent(compositionEvent('compositionstart'))
|
||||
textarea.dispatchEvent(compositionEvent('compositionupdate', '한'))
|
||||
textarea.value = '한'
|
||||
textarea.setSelectionRange(1, 1)
|
||||
await nextEventLoop()
|
||||
|
||||
textarea.dispatchEvent(compositionEvent('compositionend', '한'))
|
||||
options.beforeWon?.(textarea)
|
||||
for (const [index, committedWonText] of committedWonTexts.entries()) {
|
||||
textarea.dispatchEvent(keyboardEvent('keydown', '₩'))
|
||||
textarea.dispatchEvent(keyboardEvent('keypress', committedWonText))
|
||||
textarea.value += committedWonText
|
||||
textarea.dispatchEvent(
|
||||
new InputEvent('input', {
|
||||
data: committedWonText,
|
||||
inputType: 'insertText',
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
if (index === 0 && options.duplicateFirstInput) {
|
||||
textarea.dispatchEvent(
|
||||
new InputEvent('input', {
|
||||
data: committedWonText,
|
||||
inputType: 'insertText',
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
}
|
||||
textarea.dispatchEvent(keyboardEvent('keyup', '₩'))
|
||||
}
|
||||
await nextEventLoop()
|
||||
|
||||
forwarder.dispose()
|
||||
tracker.dispose()
|
||||
terminal.dispose()
|
||||
return emitted.join('')
|
||||
}
|
||||
|
||||
async function typeHangulThenRepeatedStaleEndsThenWon(): Promise<string> {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const terminal = new Terminal()
|
||||
terminal.open(container)
|
||||
const textarea = terminal.textarea
|
||||
const terminalElement = terminal.element
|
||||
if (!textarea || !terminalElement) {
|
||||
throw new Error('xterm input elements were not created')
|
||||
}
|
||||
|
||||
const tracker = installTerminalImeCompositionTracker(terminalElement)
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement,
|
||||
isComposing: tracker.isActive,
|
||||
sendInput: (data) => terminal.input(data)
|
||||
})
|
||||
terminal.attachCustomKeyEventHandler((event) => !forwarder.claimKeyEvent(event))
|
||||
const emitted: string[] = []
|
||||
terminal.onData((data) => emitted.push(data))
|
||||
|
||||
textarea.dispatchEvent(compositionEvent('compositionstart'))
|
||||
textarea.dispatchEvent(compositionEvent('compositionupdate', '한'))
|
||||
textarea.value = '한'
|
||||
textarea.setSelectionRange(1, 1)
|
||||
textarea.dispatchEvent(compositionEvent('compositionend', '한'))
|
||||
await nextEventLoop()
|
||||
|
||||
textarea.value = ''
|
||||
for (let index = 0; index < 3; index++) {
|
||||
textarea.dispatchEvent(compositionEvent('compositionend', '한'))
|
||||
}
|
||||
textarea.dispatchEvent(keyboardEvent('keydown', '₩'))
|
||||
textarea.dispatchEvent(keyboardEvent('keypress', '`'))
|
||||
textarea.value = '`'
|
||||
textarea.dispatchEvent(
|
||||
new InputEvent('input', { data: '`', inputType: 'insertText', bubbles: true })
|
||||
)
|
||||
textarea.dispatchEvent(keyboardEvent('keyup', '₩'))
|
||||
await nextEventLoop()
|
||||
|
||||
forwarder.dispose()
|
||||
tracker.dispose()
|
||||
terminal.dispose()
|
||||
return emitted.join('')
|
||||
}
|
||||
|
||||
describe('Korean won input after a composition commit', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
measureText: () => ({ width: 10 })
|
||||
} as unknown as CanvasRenderingContext2D)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it.each(['`', '₩'] as const)(
|
||||
'preserves composition-first order when won commits as %s',
|
||||
async (committedWonText) => {
|
||||
await expect(typeHangulThenWon([committedWonText])).resolves.toBe(`한${committedWonText}`)
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves back-to-back won transactions before the composition finalizer', async () => {
|
||||
await expect(typeHangulThenWon(['`', '₩'])).resolves.toBe('한`₩')
|
||||
})
|
||||
|
||||
it('releases composition ownership when an ordinary keydown flushes the finalizer', async () => {
|
||||
await expect(
|
||||
typeHangulThenWon(['₩'], {
|
||||
beforeWon: (textarea) => {
|
||||
textarea.dispatchEvent(keyboardEvent('keydown', 'a', 'KeyA', 65))
|
||||
}
|
||||
})
|
||||
).resolves.toBe('한a₩')
|
||||
})
|
||||
|
||||
it('suppresses a duplicate insertText without a new keydown or textarea mutation', async () => {
|
||||
await expect(typeHangulThenWon(['`'], { duplicateFirstInput: true })).resolves.toBe('한`')
|
||||
})
|
||||
|
||||
it('preserves a 64-input native-text burst before the composition finalizer', async () => {
|
||||
const committed = Array.from({ length: 64 }, (_, index) => (index % 2 === 0 ? '`' : '₩'))
|
||||
await expect(typeHangulThenWon(committed)).resolves.toBe(`한${committed.join('')}`)
|
||||
})
|
||||
|
||||
it('preserves an immediate won remap after repeated stale composition ends', async () => {
|
||||
await expect(typeHangulThenRepeatedStaleEndsThenWon()).resolves.toBe('한`')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { createRequire } from 'node:module'
|
||||
import { Terminal as EsmTerminal } from '@xterm/xterm'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const requireFromHere = createRequire(import.meta.url)
|
||||
const { Terminal: CjsTerminal } = requireFromHere('@xterm/xterm') as {
|
||||
Terminal: typeof EsmTerminal
|
||||
}
|
||||
|
||||
function nextEventLoop(): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function openTerminal(TerminalType: typeof EsmTerminal): {
|
||||
emitted: string[]
|
||||
terminal: EsmTerminal
|
||||
textarea: HTMLTextAreaElement
|
||||
} {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const terminal = new TerminalType()
|
||||
terminal.open(container)
|
||||
if (!terminal.textarea) {
|
||||
throw new Error('xterm textarea was not created')
|
||||
}
|
||||
const emitted: string[] = []
|
||||
terminal.onData((data) => emitted.push(data))
|
||||
return { emitted, terminal, textarea: terminal.textarea }
|
||||
}
|
||||
|
||||
function composition(
|
||||
textarea: HTMLTextAreaElement,
|
||||
type: 'compositionstart' | 'compositionupdate' | 'compositionend',
|
||||
data?: string
|
||||
): void {
|
||||
const event = new CompositionEvent(type, { bubbles: true })
|
||||
if (data !== undefined) {
|
||||
Object.defineProperty(event, 'data', { value: data })
|
||||
}
|
||||
textarea.dispatchEvent(event)
|
||||
}
|
||||
|
||||
function start(textarea: HTMLTextAreaElement, text: string): void {
|
||||
textarea.setSelectionRange(textarea.value.length, textarea.value.length)
|
||||
composition(textarea, 'compositionstart')
|
||||
composition(textarea, 'compositionupdate', text)
|
||||
textarea.value += text
|
||||
textarea.setSelectionRange(textarea.value.length, textarea.value.length)
|
||||
}
|
||||
|
||||
function compositionState(terminal: EsmTerminal): {
|
||||
endTimer?: unknown
|
||||
positionTimer?: unknown
|
||||
timers: Set<unknown>
|
||||
viewTimer?: unknown
|
||||
} {
|
||||
const helper = (
|
||||
terminal as unknown as {
|
||||
_core: {
|
||||
_compositionHelper: {
|
||||
_compositionEndTimer?: unknown
|
||||
_compositionPositionTimer?: unknown
|
||||
_compositionTimers: Set<unknown>
|
||||
_compositionViewTimer?: unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
)._core._compositionHelper
|
||||
return {
|
||||
endTimer: helper._compositionEndTimer,
|
||||
positionTimer: helper._compositionPositionTimer,
|
||||
timers: helper._compositionTimers,
|
||||
viewTimer: helper._compositionViewTimer
|
||||
}
|
||||
}
|
||||
|
||||
describe.each([
|
||||
['ESM', EsmTerminal],
|
||||
['CJS', CjsTerminal]
|
||||
])('installed xterm adversarial composition ownership (%s)', (_format, TerminalType) => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
measureText: () => ({ width: 10 })
|
||||
} as unknown as CanvasRenderingContext2D)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('rejects a stale end between an immediate restart and its first update', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal(TerminalType)
|
||||
start(textarea, 'A')
|
||||
await nextEventLoop()
|
||||
composition(textarea, 'compositionend', 'A')
|
||||
|
||||
textarea.setSelectionRange(1, 1)
|
||||
composition(textarea, 'compositionstart')
|
||||
composition(textarea, 'compositionend', 'A')
|
||||
await nextEventLoop()
|
||||
composition(textarea, 'compositionupdate', 'B')
|
||||
textarea.value = 'AB'
|
||||
textarea.setSelectionRange(2, 2)
|
||||
composition(textarea, 'compositionend', 'B')
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('AB')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('accepts a repeated no-update commit after textarea progress', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal(TerminalType)
|
||||
start(textarea, '가')
|
||||
composition(textarea, 'compositionend', '가')
|
||||
composition(textarea, 'compositionstart')
|
||||
textarea.value = '가가'
|
||||
textarea.setSelectionRange(2, 2)
|
||||
composition(textarea, 'compositionend', '가')
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('가가')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('accepts repeated no-update data when native progress follows the end event', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal(TerminalType)
|
||||
const lifecycle: string[] = []
|
||||
textarea.addEventListener('xterm-composition-transaction-accepted', () =>
|
||||
lifecycle.push('accepted')
|
||||
)
|
||||
textarea.addEventListener('xterm-composition-transaction-settled', () =>
|
||||
lifecycle.push('settled')
|
||||
)
|
||||
start(textarea, '가')
|
||||
composition(textarea, 'compositionend', '가')
|
||||
composition(textarea, 'compositionstart')
|
||||
composition(textarea, 'compositionend', '가')
|
||||
textarea.value = '가가'
|
||||
textarea.setSelectionRange(2, 2)
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('가가')
|
||||
expect(lifecycle).toEqual(['accepted', 'settled', 'accepted', 'settled'])
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('rejects a data-less stale end before restarted progress', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal(TerminalType)
|
||||
start(textarea, 'A')
|
||||
composition(textarea, 'compositionend', 'A')
|
||||
composition(textarea, 'compositionstart')
|
||||
composition(textarea, 'compositionend')
|
||||
await nextEventLoop()
|
||||
composition(textarea, 'compositionupdate', 'B')
|
||||
textarea.value = 'AB'
|
||||
textarea.setSelectionRange(2, 2)
|
||||
composition(textarea, 'compositionend', 'B')
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('AB')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('rejects a stale end after update but before textarea progress', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal(TerminalType)
|
||||
start(textarea, 'A')
|
||||
composition(textarea, 'compositionend', 'A')
|
||||
composition(textarea, 'compositionstart')
|
||||
composition(textarea, 'compositionupdate', 'B')
|
||||
composition(textarea, 'compositionend', 'A')
|
||||
await nextEventLoop()
|
||||
textarea.value = 'AB'
|
||||
textarea.setSelectionRange(2, 2)
|
||||
composition(textarea, 'compositionend', 'B')
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('AB')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('bounds tracked timers during same-task transaction bursts', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal(TerminalType)
|
||||
let maximumTimerCount = 0
|
||||
for (let index = 0; index < 256; index++) {
|
||||
start(textarea, '가')
|
||||
composition(textarea, 'compositionend', '가')
|
||||
maximumTimerCount = Math.max(maximumTimerCount, compositionState(terminal).timers.size)
|
||||
}
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('가'.repeat(256))
|
||||
expect(maximumTimerCount).toBeLessThanOrEqual(4)
|
||||
expect(compositionState(terminal).timers.size).toBe(0)
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('keeps newer timer slots when canceled callbacks are forced', () => {
|
||||
const { terminal, textarea } = openTerminal(TerminalType)
|
||||
const callbacks: (() => void)[] = []
|
||||
const cleared = new Set<object>()
|
||||
vi.spyOn(globalThis, 'setTimeout').mockImplementation(((callback: () => void) => {
|
||||
const token = {}
|
||||
callbacks.push(() => {
|
||||
if (!cleared.has(token)) {
|
||||
callback()
|
||||
}
|
||||
})
|
||||
return token
|
||||
}) as typeof setTimeout)
|
||||
vi.spyOn(globalThis, 'clearTimeout').mockImplementation(((token: object) => {
|
||||
cleared.add(token)
|
||||
}) as typeof clearTimeout)
|
||||
|
||||
start(textarea, 'A')
|
||||
const oldState = compositionState(terminal)
|
||||
composition(textarea, 'compositionstart')
|
||||
composition(textarea, 'compositionend', 'A')
|
||||
const staleEndTimer = compositionState(terminal).endTimer
|
||||
composition(textarea, 'compositionupdate', 'B')
|
||||
const newState = compositionState(terminal)
|
||||
for (const callback of callbacks) {
|
||||
callback()
|
||||
}
|
||||
|
||||
expect(cleared).toContain(oldState.positionTimer)
|
||||
expect(cleared).toContain(oldState.viewTimer)
|
||||
expect(cleared).toContain(staleEndTimer)
|
||||
expect(newState.positionTimer).not.toBe(oldState.positionTimer)
|
||||
expect(newState.viewTimer).not.toBe(oldState.viewTimer)
|
||||
expect(compositionState(terminal).positionTimer).toBe(newState.positionTimer)
|
||||
expect(compositionState(terminal).viewTimer).toBe(newState.viewTimer)
|
||||
expect(compositionState(terminal).timers.size).toBe(0)
|
||||
terminal.dispose()
|
||||
})
|
||||
})
|
||||
|
|
@ -6,14 +6,14 @@ function nextEventLoop(): Promise<void> {
|
|||
return new Promise((resolve) => window.setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function openTerminal(): {
|
||||
function openTerminal(screenReaderMode = false): {
|
||||
emitted: string[]
|
||||
terminal: Terminal
|
||||
textarea: HTMLTextAreaElement
|
||||
} {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const terminal = new Terminal()
|
||||
const terminal = new Terminal({ screenReaderMode })
|
||||
terminal.open(container)
|
||||
const textarea = terminal.textarea
|
||||
if (!textarea) {
|
||||
|
|
@ -24,12 +24,103 @@ function openTerminal(): {
|
|||
return { emitted, terminal, textarea }
|
||||
}
|
||||
|
||||
function dispatchCompositionEvent(
|
||||
textarea: HTMLTextAreaElement,
|
||||
type: 'compositionstart' | 'compositionupdate' | 'compositionend',
|
||||
data: string = ''
|
||||
): void {
|
||||
const event = new CompositionEvent(type, { bubbles: true })
|
||||
// happy-dom ignores CompositionEventInit.data, but Chromium supplies it.
|
||||
Object.defineProperty(event, 'data', { value: data })
|
||||
textarea.dispatchEvent(event)
|
||||
}
|
||||
|
||||
function startComposition(textarea: HTMLTextAreaElement, text: string): void {
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }))
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: text, bubbles: true }))
|
||||
dispatchCompositionEvent(textarea, 'compositionstart')
|
||||
dispatchCompositionEvent(textarea, 'compositionupdate', text)
|
||||
textarea.value = text
|
||||
}
|
||||
|
||||
function dispatchKeydown(
|
||||
textarea: HTMLTextAreaElement,
|
||||
key: string,
|
||||
code: string,
|
||||
keyCode: number,
|
||||
isComposing = false,
|
||||
timeStamp?: number
|
||||
): void {
|
||||
const keydown = new KeyboardEvent('keydown', { key, code, isComposing, bubbles: true })
|
||||
Object.defineProperty(keydown, 'keyCode', { value: keyCode })
|
||||
if (timeStamp !== undefined) {
|
||||
Object.defineProperty(keydown, 'timeStamp', { value: timeStamp })
|
||||
}
|
||||
textarea.dispatchEvent(keydown)
|
||||
}
|
||||
|
||||
function dispatchComposedInput(textarea: HTMLTextAreaElement, init: InputEventInit): void {
|
||||
const input = new InputEvent('input', { ...init, bubbles: true })
|
||||
// happy-dom ignores InputEventInit.composed, but Chromium reports it for this IBus path.
|
||||
Object.defineProperty(input, 'composed', { value: true })
|
||||
textarea.dispatchEvent(input)
|
||||
}
|
||||
|
||||
function typeObservedAscii(textarea: HTMLTextAreaElement, text: string): void {
|
||||
for (const character of text) {
|
||||
dispatchKeydown(textarea, character, `Key${character.toUpperCase()}`, character.charCodeAt(0))
|
||||
textarea.value += character
|
||||
dispatchComposedInput(textarea, { data: character, inputType: 'insertText' })
|
||||
}
|
||||
}
|
||||
|
||||
function updateObservedIbusComposition(
|
||||
textarea: HTMLTextAreaElement,
|
||||
prefix: string,
|
||||
text: string
|
||||
): void {
|
||||
dispatchCompositionEvent(textarea, 'compositionupdate', text)
|
||||
textarea.value = `${prefix}${text}`
|
||||
dispatchComposedInput(textarea, { data: text, inputType: 'insertCompositionText' })
|
||||
}
|
||||
|
||||
function startObservedIbusComposition(textarea: HTMLTextAreaElement, text: string): string {
|
||||
const prefix = textarea.value
|
||||
textarea.setSelectionRange(prefix.length, prefix.length)
|
||||
dispatchCompositionEvent(textarea, 'compositionstart')
|
||||
dispatchKeydown(textarea, 'Process', 'KeyG', 229)
|
||||
updateObservedIbusComposition(textarea, prefix, text)
|
||||
return prefix
|
||||
}
|
||||
|
||||
function endObservedIbusComposition(textarea: HTMLTextAreaElement, prefix: string): void {
|
||||
dispatchCompositionEvent(textarea, 'compositionupdate')
|
||||
textarea.value = prefix
|
||||
dispatchComposedInput(textarea, { inputType: 'deleteContentBackward' })
|
||||
dispatchCompositionEvent(textarea, 'compositionend')
|
||||
}
|
||||
|
||||
function commitObservedIbusComposition(
|
||||
textarea: HTMLTextAreaElement,
|
||||
prefix: string,
|
||||
text: string
|
||||
): void {
|
||||
endObservedIbusComposition(textarea, prefix)
|
||||
textarea.value = `${prefix}${text}`
|
||||
dispatchComposedInput(textarea, { data: text, inputType: 'insertText' })
|
||||
}
|
||||
|
||||
function typeObservedIbusCommit(textarea: HTMLTextAreaElement, text: string): void {
|
||||
const prefix = startObservedIbusComposition(textarea, text)
|
||||
commitObservedIbusComposition(textarea, prefix, text)
|
||||
}
|
||||
|
||||
function typeObservedIbusKeypressCommit(textarea: HTMLTextAreaElement, text: string): void {
|
||||
const prefix = startObservedIbusComposition(textarea, text)
|
||||
endObservedIbusComposition(textarea, prefix)
|
||||
dispatchKeypress(textarea, text)
|
||||
textarea.value = `${prefix}${text}`
|
||||
dispatchComposedInput(textarea, { data: text, inputType: 'insertText' })
|
||||
}
|
||||
|
||||
function dispatchKeypress(textarea: HTMLTextAreaElement, text: string): void {
|
||||
const keypress = new KeyboardEvent('keypress', { key: text, bubbles: true })
|
||||
// happy-dom omits Chromium's legacy charCode field that xterm still reads.
|
||||
|
|
@ -37,6 +128,25 @@ function dispatchKeypress(textarea: HTMLTextAreaElement, text: string): void {
|
|||
textarea.dispatchEvent(keypress)
|
||||
}
|
||||
|
||||
function getPendingFinalizationCount(terminal: Terminal): number {
|
||||
const pending = (
|
||||
terminal as unknown as {
|
||||
_core: { _compositionHelper: { _pendingComposition?: unknown } }
|
||||
}
|
||||
)._core._compositionHelper._pendingComposition
|
||||
return pending === undefined ? 0 : 1
|
||||
}
|
||||
|
||||
function getCompositionHelper(terminal: Terminal): {
|
||||
keypress(text: string): boolean
|
||||
} {
|
||||
return (
|
||||
terminal as unknown as {
|
||||
_core: { _compositionHelper: { keypress(text: string): boolean } }
|
||||
}
|
||||
)._core._compositionHelper
|
||||
}
|
||||
|
||||
describe('xterm IME composition de-duplication', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
|
|
@ -49,6 +159,404 @@ describe('xterm IME composition de-duplication', () => {
|
|||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('emits a propagated IBus Hangul commit exactly once', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
|
||||
typeObservedIbusCommit(textarea, '한')
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('한')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('does not drop an unpropagated IBus commit before the following keydown', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
|
||||
startObservedIbusComposition(textarea, '한')
|
||||
dispatchCompositionEvent(textarea, 'compositionend', '한')
|
||||
dispatchKeydown(textarea, 'a', 'KeyA', 65)
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('한a')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('preserves a macOS Hangul candidate when the input source changes to ASCII', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
|
||||
startComposition(textarea, '한')
|
||||
dispatchCompositionEvent(textarea, 'compositionupdate', 'a')
|
||||
textarea.value = '한a'
|
||||
dispatchComposedInput(textarea, { data: 'a', inputType: 'insertText' })
|
||||
dispatchCompositionEvent(textarea, 'compositionend', 'a')
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('한a')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('leaves ordinary ASCII outside composition bookkeeping', () => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
|
||||
typeObservedAscii(textarea, 'abcdefghijklmno')
|
||||
|
||||
expect(emitted.join('')).toBe('abcdefghijklmno')
|
||||
expect(getPendingFinalizationCount(terminal)).toBe(0)
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it.each(['日本語', '中文'])('preserves a propagated %s IME commit', async (text) => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
|
||||
typeObservedIbusCommit(textarea, text)
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe(text)
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('uses the selected Chinese candidate instead of its Pinyin preedit', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
|
||||
dispatchCompositionEvent(textarea, 'compositionstart')
|
||||
dispatchCompositionEvent(textarea, 'compositionupdate', 'ni')
|
||||
textarea.value = 'ni'
|
||||
dispatchCompositionEvent(textarea, 'compositionend', '你')
|
||||
textarea.value = '你'
|
||||
dispatchComposedInput(textarea, { data: '你', inputType: 'insertText' })
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('你')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('keeps an unmatched Japanese keypress after its composition', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
startComposition(textarea, '日')
|
||||
await nextEventLoop()
|
||||
|
||||
dispatchCompositionEvent(textarea, 'compositionend', '日')
|
||||
dispatchKeypress(textarea, '本')
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('日本')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('keeps unrelated text after the composition that preceded it', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
startComposition(textarea, '漢')
|
||||
await nextEventLoop()
|
||||
|
||||
dispatchCompositionEvent(textarea, 'compositionend', '漢')
|
||||
dispatchKeypress(textarea, 'x')
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('漢x')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['日本', '本'],
|
||||
['가나다', '다'],
|
||||
['🇰🇷', '🇰'],
|
||||
['👩💻', '💻'],
|
||||
['가', '가'],
|
||||
['👍🏽', '👍🏽'],
|
||||
['e\u0301', '\u0301'],
|
||||
['中文', '文'],
|
||||
['한', 'a'],
|
||||
['a', 'a']
|
||||
])('keeps immediately-following %s then %s input distinct', async (composition, following) => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
startComposition(textarea, composition)
|
||||
await nextEventLoop()
|
||||
|
||||
dispatchCompositionEvent(textarea, 'compositionend', composition)
|
||||
textarea.value = `${composition}${following}`
|
||||
dispatchComposedInput(textarea, { data: following, inputType: 'insertText' })
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe(`${composition}${following}`)
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('emits repeated Korean insertText within one composition exactly twice', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
startComposition(textarea, '가가')
|
||||
await nextEventLoop()
|
||||
|
||||
dispatchComposedInput(textarea, { data: '가', inputType: 'insertText' })
|
||||
dispatchComposedInput(textarea, { data: '가', inputType: 'insertText' })
|
||||
dispatchCompositionEvent(textarea, 'compositionend', '가가')
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('가가')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('owns Chinese insertText that arrives before compositionend', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
startComposition(textarea, '中文')
|
||||
await nextEventLoop()
|
||||
|
||||
dispatchComposedInput(textarea, { data: '中文', inputType: 'insertText' })
|
||||
dispatchCompositionEvent(textarea, 'compositionend', '中文')
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('中文')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('does not scan large unrelated observations for partial overlap', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
const composition = 'a'.repeat(10_000)
|
||||
const following = 'b'.repeat(50_000)
|
||||
startComposition(textarea, composition)
|
||||
await nextEventLoop()
|
||||
dispatchCompositionEvent(textarea, 'compositionend', composition)
|
||||
const endsWith = vi.spyOn(String.prototype, 'endsWith')
|
||||
const startsWith = vi.spyOn(String.prototype, 'startsWith')
|
||||
|
||||
expect(getCompositionHelper(terminal).keypress(following)).toBe(false)
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe(composition)
|
||||
expect(endsWith).not.toHaveBeenCalled()
|
||||
expect(startsWith).not.toHaveBeenCalled()
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('preserves a Korean final-consonant transfer across compositions', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
|
||||
startObservedIbusComposition(textarea, 'ㅇ')
|
||||
updateObservedIbusComposition(textarea, '', '아')
|
||||
updateObservedIbusComposition(textarea, '', '앙')
|
||||
dispatchCompositionEvent(textarea, 'compositionend', '앙')
|
||||
textarea.setSelectionRange(1, 1)
|
||||
dispatchCompositionEvent(textarea, 'compositionstart')
|
||||
updateObservedIbusComposition(textarea, '아', '아')
|
||||
dispatchCompositionEvent(textarea, 'compositionend', '아')
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('아아')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('uses the authoritative IBus insertText after a final-consonant transfer', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
const prefix = startObservedIbusComposition(textarea, '텟')
|
||||
await nextEventLoop()
|
||||
|
||||
endObservedIbusComposition(textarea, prefix)
|
||||
textarea.value = '테'
|
||||
dispatchComposedInput(textarea, { data: '테', inputType: 'insertText' })
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('테')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('does not let stale timers leak across composition transactions', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
|
||||
startObservedIbusComposition(textarea, '테')
|
||||
dispatchCompositionEvent(textarea, 'compositionend', '테')
|
||||
dispatchKeydown(textarea, 'a', 'KeyA', 65)
|
||||
dispatchKeydown(textarea, 'Process', 'KeyR', 229)
|
||||
textarea.setSelectionRange(1, 1)
|
||||
dispatchCompositionEvent(textarea, 'compositionstart')
|
||||
updateObservedIbusComposition(textarea, '테', '스')
|
||||
commitObservedIbusComposition(textarea, '테', '스')
|
||||
dispatchKeydown(textarea, 'Enter', 'Enter', 13)
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('테a스\r')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('keeps a deferred composition update scoped to its transaction', async () => {
|
||||
const { terminal, textarea } = openTerminal()
|
||||
|
||||
startObservedIbusComposition(textarea, '한')
|
||||
dispatchCompositionEvent(textarea, 'compositionend', '한')
|
||||
dispatchComposedInput(textarea, { data: '한', inputType: 'insertText' })
|
||||
textarea.setSelectionRange(1, 1)
|
||||
dispatchCompositionEvent(textarea, 'compositionstart')
|
||||
textarea.value = '한글'
|
||||
textarea.setSelectionRange(2, 2)
|
||||
await nextEventLoop()
|
||||
|
||||
const compositionPosition = (
|
||||
terminal as unknown as {
|
||||
_core: { _compositionHelper: { _compositionPosition: { start: number; end: number } } }
|
||||
}
|
||||
)._core._compositionHelper._compositionPosition
|
||||
expect(compositionPosition).toEqual({ start: 1, end: 1 })
|
||||
dispatchCompositionEvent(textarea, 'compositionend', '글')
|
||||
await nextEventLoop()
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('preserves a retained commit before a no-keydown text insertion', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
|
||||
startObservedIbusComposition(textarea, '한')
|
||||
dispatchCompositionEvent(textarea, 'compositionend', '한')
|
||||
textarea.value = '한x'
|
||||
dispatchComposedInput(textarea, { data: 'x', inputType: 'insertText' })
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('한x')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('keeps screen-reader trailing text outside an authoritative commit', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal(true)
|
||||
|
||||
textarea.value = '一二'
|
||||
textarea.setSelectionRange(1, 1)
|
||||
dispatchCompositionEvent(textarea, 'compositionstart')
|
||||
dispatchCompositionEvent(textarea, 'compositionupdate', '一')
|
||||
textarea.value = '一一二'
|
||||
textarea.setSelectionRange(2, 2)
|
||||
dispatchCompositionEvent(textarea, 'compositionend', '一')
|
||||
dispatchComposedInput(textarea, { data: '一', inputType: 'insertText' })
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('一')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('flushes an unpropagated Hangul commit before Enter', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
|
||||
startObservedIbusComposition(textarea, '한')
|
||||
dispatchCompositionEvent(textarea, 'compositionend', '한')
|
||||
dispatchKeydown(textarea, 'Enter', 'Enter', 13)
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('한\r')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('deduplicates each commit without suppressing a legitimate repeated syllable', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
|
||||
typeObservedIbusKeypressCommit(textarea, '가')
|
||||
typeObservedIbusKeypressCommit(textarea, '가')
|
||||
dispatchKeydown(textarea, 'Enter', 'Enter', 13)
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('가가\r')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('ignores a duplicate compositionend for the same transaction', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
|
||||
startComposition(textarea, '한')
|
||||
dispatchCompositionEvent(textarea, 'compositionend', '한')
|
||||
dispatchCompositionEvent(textarea, 'compositionend', '한')
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('한')
|
||||
expect(getPendingFinalizationCount(terminal)).toBe(0)
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('ignores a stale compositionend after the next transaction starts', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
|
||||
dispatchCompositionEvent(textarea, 'compositionstart')
|
||||
dispatchCompositionEvent(textarea, 'compositionupdate', 'A')
|
||||
textarea.value = 'A'
|
||||
textarea.setSelectionRange(1, 1)
|
||||
dispatchCompositionEvent(textarea, 'compositionend', 'A')
|
||||
|
||||
dispatchCompositionEvent(textarea, 'compositionstart')
|
||||
dispatchCompositionEvent(textarea, 'compositionupdate', 'B')
|
||||
dispatchCompositionEvent(textarea, 'compositionend', 'A')
|
||||
dispatchCompositionEvent(textarea, 'compositionend', 'B')
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('AB')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('bounds pending finalizations during synchronous composition turnover', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
|
||||
for (let index = 0; index < 20; index++) {
|
||||
textarea.setSelectionRange(textarea.value.length, textarea.value.length)
|
||||
dispatchCompositionEvent(textarea, 'compositionstart')
|
||||
dispatchCompositionEvent(textarea, 'compositionupdate', '가')
|
||||
textarea.value += '가'
|
||||
dispatchCompositionEvent(textarea, 'compositionend', '가')
|
||||
expect(getPendingFinalizationCount(terminal)).toBeLessThanOrEqual(1)
|
||||
}
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('가'.repeat(20))
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('keeps matching text when a new composition restarts immediately', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
startComposition(textarea, '日本')
|
||||
await nextEventLoop()
|
||||
dispatchCompositionEvent(textarea, 'compositionend', '日本')
|
||||
|
||||
textarea.setSelectionRange(2, 2)
|
||||
dispatchCompositionEvent(textarea, 'compositionstart')
|
||||
dispatchCompositionEvent(textarea, 'compositionupdate', '本')
|
||||
textarea.value = '日本本'
|
||||
textarea.setSelectionRange(3, 3)
|
||||
dispatchCompositionEvent(textarea, 'compositionend', '本')
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('日本本')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('flushes a pending commit before blur clears the textarea', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
terminal.focus()
|
||||
startComposition(textarea, '한')
|
||||
|
||||
dispatchCompositionEvent(textarea, 'compositionend', '한')
|
||||
textarea.blur()
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('한')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('flushes an active composition before blur clears the textarea', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
terminal.focus()
|
||||
startComposition(textarea, '한')
|
||||
|
||||
textarea.blur()
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('한')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('does not emit deferred composition data after disposal', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
startComposition(textarea, '한')
|
||||
dispatchCompositionEvent(textarea, 'compositionend', '한')
|
||||
|
||||
terminal.dispose()
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted).toEqual([])
|
||||
expect(getPendingFinalizationCount(terminal)).toBe(0)
|
||||
})
|
||||
|
||||
it('emits a post-composition IBus Hangul keypress only once', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
startComposition(textarea, '한')
|
||||
|
|
@ -57,20 +565,17 @@ describe('xterm IME composition de-duplication', () => {
|
|||
// Why: IBus clears at compositionend, then restores the same commit after
|
||||
// xterm's keypress path has already emitted it.
|
||||
textarea.value = ''
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }))
|
||||
dispatchCompositionEvent(textarea, 'compositionend')
|
||||
const compositionHelper = (
|
||||
terminal as unknown as {
|
||||
_core: {
|
||||
_compositionHelper: {
|
||||
_isSendingComposition: boolean
|
||||
_pendingKeypressData: string
|
||||
}
|
||||
_compositionHelper: { _pendingComposition?: { keypressData: string } }
|
||||
}
|
||||
}
|
||||
)._core._compositionHelper
|
||||
expect(compositionHelper._isSendingComposition).toBe(true)
|
||||
expect(compositionHelper._pendingComposition).toBeDefined()
|
||||
dispatchKeypress(textarea, '한')
|
||||
expect(compositionHelper._pendingKeypressData).toBe('한')
|
||||
expect(compositionHelper._pendingComposition?.keypressData).toBe('한')
|
||||
expect(emitted).toEqual([])
|
||||
textarea.value = '한'
|
||||
textarea.dispatchEvent(
|
||||
|
|
@ -82,57 +587,55 @@ describe('xterm IME composition de-duplication', () => {
|
|||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('preserves composition-first order when keypress overlaps its suffix', async () => {
|
||||
it('keeps a following keypress even when it matches the composition suffix', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
startComposition(textarea, '가한')
|
||||
await nextEventLoop()
|
||||
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }))
|
||||
dispatchCompositionEvent(textarea, 'compositionend')
|
||||
dispatchKeypress(textarea, '한')
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('가한')
|
||||
expect(emitted.join('')).toBe('가한한')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('emits unmatched keypress before propagated composition text', async () => {
|
||||
it('emits propagated composition text before an unmatched keypress', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
startComposition(textarea, '한')
|
||||
await nextEventLoop()
|
||||
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: 'a', bubbles: true }))
|
||||
dispatchCompositionEvent(textarea, 'compositionupdate', 'a')
|
||||
textarea.value = 'a'
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }))
|
||||
dispatchCompositionEvent(textarea, 'compositionend')
|
||||
dispatchKeypress(textarea, '한')
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('한a')
|
||||
expect(emitted.join('')).toBe('a한')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('does not repeat keypress contained before propagated composition text', async () => {
|
||||
it('keeps a following keypress even when it matches the composition prefix', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
startComposition(textarea, '한')
|
||||
await nextEventLoop()
|
||||
|
||||
textarea.dispatchEvent(
|
||||
new CompositionEvent('compositionupdate', { data: '한a', bubbles: true })
|
||||
)
|
||||
dispatchCompositionEvent(textarea, 'compositionupdate', '한a')
|
||||
textarea.value = '한a'
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }))
|
||||
dispatchCompositionEvent(textarea, 'compositionend')
|
||||
dispatchKeypress(textarea, '한')
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('한a')
|
||||
expect(emitted.join('')).toBe('한a한')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('merges multiple deferred keypresses with partial textarea overlap', async () => {
|
||||
it('preserves multiple keypresses when textarea propagation is partial', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
startComposition(textarea, '한')
|
||||
await nextEventLoop()
|
||||
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }))
|
||||
dispatchCompositionEvent(textarea, 'compositionend')
|
||||
dispatchKeypress(textarea, 'a')
|
||||
dispatchKeypress(textarea, 'b')
|
||||
textarea.value = '한a'
|
||||
|
|
@ -148,7 +651,7 @@ describe('xterm IME composition de-duplication', () => {
|
|||
await nextEventLoop()
|
||||
|
||||
textarea.value = ''
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }))
|
||||
dispatchCompositionEvent(textarea, 'compositionend')
|
||||
dispatchKeypress(textarea, '한')
|
||||
textarea.value = '한'
|
||||
const keydown = new KeyboardEvent('keydown', { key: 'a', code: 'KeyA', bubbles: true })
|
||||
|
|
@ -159,4 +662,85 @@ describe('xterm IME composition de-duplication', () => {
|
|||
expect(emitted.join('')).toBe('한a')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('preserves macOS Korean commit boundaries across blur and refocus', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
startComposition(textarea, '하')
|
||||
dispatchCompositionEvent(textarea, 'compositionend', '하')
|
||||
textarea.dispatchEvent(new FocusEvent('blur'))
|
||||
textarea.dispatchEvent(new FocusEvent('focus'))
|
||||
startComposition(textarea, 'ㄴ ')
|
||||
dispatchCompositionEvent(textarea, 'compositionend', 'ㄴ ')
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('하ㄴ ')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('commits the visible composition when pane blur precedes compositionend', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
startComposition(textarea, '한')
|
||||
await nextEventLoop()
|
||||
|
||||
textarea.dispatchEvent(new FocusEvent('blur'))
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: '한', bubbles: true }))
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('한')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('commits to the composing terminal when focus switches to another terminal', async () => {
|
||||
const original = openTerminal()
|
||||
const unrelated = openTerminal()
|
||||
startComposition(original.textarea, '한')
|
||||
await nextEventLoop()
|
||||
|
||||
original.textarea.dispatchEvent(
|
||||
new CompositionEvent('compositionend', { data: '한', bubbles: true })
|
||||
)
|
||||
original.textarea.dispatchEvent(new FocusEvent('blur'))
|
||||
unrelated.textarea.focus()
|
||||
await nextEventLoop()
|
||||
|
||||
expect(original.emitted.join('')).toBe('한')
|
||||
expect(unrelated.emitted).toEqual([])
|
||||
original.terminal.dispose()
|
||||
unrelated.terminal.dispose()
|
||||
})
|
||||
|
||||
it('finalizes source-switch cancellation before a later Korean composition', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
startComposition(textarea, '한')
|
||||
await nextEventLoop()
|
||||
|
||||
dispatchKeydown(textarea, 'Escape', 'Escape', 229, true, 100)
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: '한', bubbles: true }))
|
||||
dispatchKeydown(textarea, 'Escape', 'Escape', 27, false, 100)
|
||||
await nextEventLoop()
|
||||
expect(emitted).toEqual([])
|
||||
|
||||
textarea.value = ''
|
||||
startComposition(textarea, '한')
|
||||
await nextEventLoop()
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: '한', bubbles: true }))
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted.join('')).toBe('한')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('cancels a pending completion when Escape follows compositionend', async () => {
|
||||
const { emitted, terminal, textarea } = openTerminal()
|
||||
startComposition(textarea, '한')
|
||||
await nextEventLoop()
|
||||
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: '한', bubbles: true }))
|
||||
dispatchKeydown(textarea, 'Escape', 'Escape', 229, true, 100)
|
||||
dispatchKeydown(textarea, 'Escape', 'Escape', 27, false, 100)
|
||||
await nextEventLoop()
|
||||
|
||||
expect(emitted).toEqual([])
|
||||
terminal.dispose()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,217 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { Terminal as EsmTerminal } from '@xterm/xterm'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
installTerminalImeNativeTextForwarder,
|
||||
XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT,
|
||||
XTERM_COMPOSITION_TRANSACTION_SETTLED_EVENT
|
||||
} from './terminal-ime-native-text-forwarder'
|
||||
|
||||
const requireFromHere = createRequire(import.meta.url)
|
||||
const { Terminal: CjsTerminal } = requireFromHere('@xterm/xterm') as {
|
||||
Terminal: typeof EsmTerminal
|
||||
}
|
||||
const xtermPackageRoot = dirname(requireFromHere.resolve('@xterm/xterm/package.json'))
|
||||
const EXPECTED_XTERM_VERSION = '6.1.0-beta.287'
|
||||
|
||||
function nextEventLoop(): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function dispatchWonKeyEvent(
|
||||
textarea: HTMLTextAreaElement,
|
||||
type: 'keydown' | 'keypress' | 'keyup',
|
||||
key: string
|
||||
): void {
|
||||
const event = new KeyboardEvent(type, { key, code: 'Backquote', bubbles: true })
|
||||
Object.defineProperties(event, {
|
||||
charCode: { value: type === 'keypress' ? key.charCodeAt(0) : 0 },
|
||||
keyCode: { value: 192 },
|
||||
which: { value: type === 'keypress' ? key.charCodeAt(0) : 192 }
|
||||
})
|
||||
textarea.dispatchEvent(event)
|
||||
}
|
||||
|
||||
async function openComposedTerminal(TerminalType: typeof EsmTerminal): Promise<{
|
||||
terminal: EsmTerminal
|
||||
textarea: HTMLTextAreaElement
|
||||
events: string[]
|
||||
output: string[]
|
||||
}> {
|
||||
const terminal = new TerminalType()
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
terminal.open(container)
|
||||
if (!terminal.element || !terminal.textarea) {
|
||||
throw new Error('xterm input elements were not created')
|
||||
}
|
||||
const events: string[] = []
|
||||
const output: string[] = []
|
||||
terminal.onData((data) => output.push(data))
|
||||
terminal.element.addEventListener(XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT, () => {
|
||||
events.push('accepted')
|
||||
})
|
||||
terminal.element.addEventListener(XTERM_COMPOSITION_TRANSACTION_SETTLED_EVENT, () => {
|
||||
events.push('settled')
|
||||
})
|
||||
terminal.textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }))
|
||||
terminal.textarea.dispatchEvent(
|
||||
new CompositionEvent('compositionupdate', { data: '한', bubbles: true })
|
||||
)
|
||||
terminal.textarea.value = '한'
|
||||
terminal.textarea.setSelectionRange(1, 1)
|
||||
await nextEventLoop()
|
||||
return { terminal, textarea: terminal.textarea, events, output }
|
||||
}
|
||||
|
||||
describe.each([
|
||||
['ESM', EsmTerminal, 'xterm.mjs.map'],
|
||||
['CJS', CjsTerminal, 'xterm.js.map']
|
||||
])('xterm composition transaction events (%s)', (_format, TerminalType, sourceMapName) => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
measureText: () => ({ width: 10 })
|
||||
} as unknown as CanvasRenderingContext2D)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('keeps the runtime and mapped source package versions aligned', async () => {
|
||||
const terminal = new TerminalType()
|
||||
const output: string[] = []
|
||||
terminal.onData((data) => output.push(data))
|
||||
|
||||
await new Promise<void>((resolve) => terminal.write('\x1b[>0q', resolve))
|
||||
|
||||
expect(output).toEqual([`\x1bP>|xterm.js(${EXPECTED_XTERM_VERSION})\x1b\\`])
|
||||
const sourceMap = JSON.parse(
|
||||
readFileSync(join(xtermPackageRoot, 'lib', sourceMapName), 'utf8')
|
||||
) as { sources: string[]; sourcesContent: (string | null)[] }
|
||||
const versionSourceIndex = sourceMap.sources.findIndex((source) =>
|
||||
source.endsWith('/common/Version.ts')
|
||||
)
|
||||
expect(sourceMap.sourcesContent[versionSourceIndex]).toContain(
|
||||
`XTERM_VERSION = '${EXPECTED_XTERM_VERSION}'`
|
||||
)
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('settles after the deferred finalizer completes', async () => {
|
||||
const { terminal, textarea, events } = await openComposedTerminal(TerminalType)
|
||||
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: '한', bubbles: true }))
|
||||
expect(events).toEqual(['accepted'])
|
||||
await nextEventLoop()
|
||||
|
||||
expect(events).toEqual(['accepted', 'settled'])
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('settles synchronously when an ordinary keydown flushes the finalizer', async () => {
|
||||
const { terminal, textarea, events } = await openComposedTerminal(TerminalType)
|
||||
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: '한', bubbles: true }))
|
||||
const keydown = new KeyboardEvent('keydown', { key: 'a', code: 'KeyA', bubbles: true })
|
||||
Object.defineProperty(keydown, 'keyCode', { value: 65 })
|
||||
textarea.dispatchEvent(keydown)
|
||||
|
||||
expect(events).toEqual(['accepted', 'settled'])
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('settles the old transaction before accepting a restarted composition', async () => {
|
||||
const { terminal, textarea, events } = await openComposedTerminal(TerminalType)
|
||||
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: '한', bubbles: true }))
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }))
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: '글', bubbles: true }))
|
||||
textarea.value = '한글'
|
||||
textarea.setSelectionRange(2, 2)
|
||||
await nextEventLoop()
|
||||
expect(events).toEqual(['accepted', 'settled'])
|
||||
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: '글', bubbles: true }))
|
||||
expect(events).toEqual(['accepted', 'settled', 'accepted'])
|
||||
await nextEventLoop()
|
||||
|
||||
expect(events).toEqual(['accepted', 'settled', 'accepted', 'settled'])
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('settles every immediately completed restarted composition', async () => {
|
||||
const { terminal, textarea, events, output } = await openComposedTerminal(TerminalType)
|
||||
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: '한', bubbles: true }))
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }))
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: '글', bubbles: true }))
|
||||
textarea.value = '한글'
|
||||
textarea.setSelectionRange(2, 2)
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: '글', bubbles: true }))
|
||||
|
||||
expect(events).toEqual(['accepted', 'settled', 'accepted'])
|
||||
await nextEventLoop()
|
||||
|
||||
expect(events).toEqual(['accepted', 'settled', 'accepted', 'settled'])
|
||||
expect(output.join('')).toBe('한글')
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('rejects immediate duplicate composition ends', async () => {
|
||||
const { terminal, textarea, events, output } = await openComposedTerminal(TerminalType)
|
||||
|
||||
for (let index = 0; index < 3; index++) {
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: '한', bubbles: true }))
|
||||
}
|
||||
expect(events).toEqual(['accepted'])
|
||||
await nextEventLoop()
|
||||
|
||||
expect(events).toEqual(['accepted', 'settled'])
|
||||
expect(output).toEqual(['한'])
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('rejects stale composition ends after settlement', async () => {
|
||||
const { terminal, textarea, events, output } = await openComposedTerminal(TerminalType)
|
||||
const forwarder = installTerminalImeNativeTextForwarder({
|
||||
terminalElement: terminal.element,
|
||||
isComposing: () => false,
|
||||
sendInput: (data) => terminal.input(data)
|
||||
})
|
||||
terminal.attachCustomKeyEventHandler((event) => !forwarder.claimKeyEvent(event))
|
||||
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: '한', bubbles: true }))
|
||||
await nextEventLoop()
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: '한', bubbles: true }))
|
||||
dispatchWonKeyEvent(textarea, 'keydown', '₩')
|
||||
dispatchWonKeyEvent(textarea, 'keypress', '`')
|
||||
textarea.value = '`'
|
||||
textarea.dispatchEvent(
|
||||
new InputEvent('input', { data: '`', inputType: 'insertText', bubbles: true })
|
||||
)
|
||||
dispatchWonKeyEvent(textarea, 'keyup', '₩')
|
||||
await nextEventLoop()
|
||||
|
||||
expect(events).toEqual(['accepted', 'settled'])
|
||||
expect(output).toEqual(['한', '`'])
|
||||
forwarder.dispose()
|
||||
terminal.dispose()
|
||||
})
|
||||
|
||||
it('leaves an accepted transaction aborted when disposal cancels its finalizer', async () => {
|
||||
const { terminal, textarea, events, output } = await openComposedTerminal(TerminalType)
|
||||
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: '한', bubbles: true }))
|
||||
expect(events).toEqual(['accepted'])
|
||||
terminal.dispose()
|
||||
await nextEventLoop()
|
||||
|
||||
expect(events).toEqual(['accepted'])
|
||||
expect(output).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
# PR #11293 + PR #11177 semantic integration R1
|
||||
|
||||
## Result
|
||||
|
||||
- #11293 base: `a9d8be94df2b750c9d363fc8310567dd7e5504cd`
|
||||
- Preserved contributor commit: `eb76873ce7b9564b248f7317924e6c49b4001b13`
|
||||
(`JeongUk Park <jeongph.dev@gmail.com>`, cherry-picked from `2cb6a50e3`)
|
||||
- Integration commit: `c1b329b72f25d8701769be52d0c400c5eaf29510`
|
||||
- Finalized #11177 reference: `dc2b59723dcc8cbf2cf44ebe0511323551e9d3b3`
|
||||
- Upstream xterm source: `53a98a720ae4a973e384fa2440880d09537132f3`
|
||||
- Published runtime/source-map version: `6.1.0-beta.287`
|
||||
- Final pnpm patch hash:
|
||||
`db949d673197f31341f8cac2b25acff4cf6073725bfb6c6d3ca2ed02cfd5c898`
|
||||
|
||||
The integration retains #11293/#11011's transaction IDs, pending-composition record,
|
||||
tracked timers, input/blur/disposal ownership, stale-end rejection, and
|
||||
Japanese/Chinese/Korean reconciliation. It layers #11177's Korean won/backtick native
|
||||
forwarding, compatible optional helper interface, accepted/settled events, immediate
|
||||
restart balance, runtime version, and complete generated artifact set.
|
||||
|
||||
Immediate restart cancels the superseded finalizer timer and settles its lifecycle before
|
||||
the new transaction is accepted, while retaining the stronger pending byte record until
|
||||
the restarted composition supplies authoritative text. This preserves both
|
||||
`accepted, settled, accepted, settled` and Korean final-consonant transfer (`아아`, not
|
||||
`앙아`). When a browser/test double omits `CompositionEvent.data`, the fallback uses
|
||||
#11177's shortest ordered keypress merge; data-bearing Chromium/IBus paths retain
|
||||
#11293's distinct-following-keypress contract.
|
||||
|
||||
## Authorship and history
|
||||
|
||||
JeongUk Park's original contributor authorship is preserved in its own commit before the
|
||||
integration fix. The exact #11293 base remains intact, including its existing
|
||||
AmethystLiang and nwparker-authored history. No commit was squashed or rewritten.
|
||||
|
||||
## Red control
|
||||
|
||||
The final 16-test CJS/ESM transaction suite was run against exact round-six parent
|
||||
`be7b9b85574266d2da114e3171020db645262bc1`.
|
||||
|
||||
- Result: 2 failed, 14 passed.
|
||||
- Both failures were the intended immediate-full-restart controls.
|
||||
- Parent events were `accepted, accepted`; the candidate reports
|
||||
`accepted, settled, accepted`.
|
||||
|
||||
The candidate passes all 16 tests and produces `한글` exactly once.
|
||||
|
||||
## IME and terminal validation
|
||||
|
||||
| Gate | Result |
|
||||
| ----------------------------------------------- | ----------------------------- |
|
||||
| Exact #11011 composition suite from `a76760649` | 42/42 passed |
|
||||
| Exact #11177 ten-file matrix from `dc2b59723` | 127/127 passed |
|
||||
| Combined current IME superset | 13 files, 218/218 passed |
|
||||
| Focused transaction/won/#11293 suite | 4 files, 75/75 passed |
|
||||
| Broad terminal-pane suite | 200 files, 2,673/2,673 passed |
|
||||
| Native IBus workflow contract tests | 4/4 passed |
|
||||
|
||||
The native Linux IBus harness was not run because the integration host is macOS
|
||||
(`Darwin`); the harness requires Linux/X11 and running it here would violate the
|
||||
no-GUI/no-input-source-change constraint.
|
||||
|
||||
## Artifact and supply-chain integrity
|
||||
|
||||
A clean checkout of upstream xterm `53a98a7` received the publication substitutions and
|
||||
the semantic source union. Upstream `tsgo -b ./tsconfig.all.json`, webpack CJS, and
|
||||
production esbuild ESM builds passed. A fixture-local self-package link prevented Orca's
|
||||
ancestor `node_modules` typings from contaminating the clean upstream compile.
|
||||
|
||||
| Artifact | SHA-256 |
|
||||
| ------------------- | ------------------------------------------------------------------ |
|
||||
| `lib/xterm.js` | `81b4efd747ab4188635955ef60dcc02a4c4920039fad4c9b898ff93710ff8b3a` |
|
||||
| `lib/xterm.mjs` | `13afb1a5bb49e2bbc8ff8b2867b71b0c8e61f6fb25f49af69ac8f25b72ccf24f` |
|
||||
| `lib/xterm.js.map` | `2f4394dbed6e3a5baeab2b7fb24ce6e5f940ebbab728d30c093ff8fa0f8ebb4d` |
|
||||
| `lib/xterm.mjs.map` | `4d7b8a4775bca851c4c19aac70543b2881dad7af27e4f5397403ddb59ec3f090` |
|
||||
|
||||
- The final patch applies cleanly to the pristine npm package.
|
||||
- All patched package files match the installed pnpm package byte-for-byte.
|
||||
- All 24 lockfile references use the raw patch SHA-256.
|
||||
- Frozen and frozen-offline installs passed, including native dependency validation.
|
||||
- CJS and ESM DCS probes both report `xterm.js(6.1.0-beta.287)`.
|
||||
- Both maps contain the matching `src/common/Version.ts` source.
|
||||
|
||||
## Repository gates
|
||||
|
||||
- Node, CLI, and web typechecks: passed.
|
||||
- Full repository lint: passed, including type-aware/native code-quality, reliability,
|
||||
max-lines, localization, and bundled-skill gates.
|
||||
- Changed-code-quality and React Doctor: 0 new findings.
|
||||
- Changed TypeScript formatting: passed.
|
||||
- Electron Vite production build: passed with existing chunking/CSS warnings.
|
||||
- `git diff --check`: passed.
|
||||
|
||||
One lint invocation intentionally overlapped the Electron build and observed the build's
|
||||
ephemeral `electron.vite.config.*.mjs` disappear during scanning. The standalone final
|
||||
lint rerun passed; this was concurrent command interference, not a source failure.
|
||||
|
||||
## Latest main compatibility
|
||||
|
||||
- Fetched `origin/main`: `a7c8b8e07161ec05c44dbf2a0d11dad9c71a4710`
|
||||
- Conflict-free merge tree: `95f1aa9fa14d3664adb9aa20a3c35f0ee481979d`
|
||||
- Materialized merge frozen-offline install: passed.
|
||||
- Materialized merge IME superset: 13 files, 218/218 passed.
|
||||
- Materialized merge Node/CLI/web typechecks: passed.
|
||||
|
||||
## Security, performance, platform, and workspace audit
|
||||
|
||||
- No dependency version or dependency graph changed; `pnpm-lock.yaml` changes only the
|
||||
xterm patch hash references.
|
||||
- No filesystem, shell, Git, provider, credential, IPC, HTML injection, or network
|
||||
surface changed.
|
||||
- No unbounded timer or collection was added. Superseded finalizers are canceled and
|
||||
removed from the tracked timer set; the large-observation performance guard remains
|
||||
green.
|
||||
- macOS won/backtick routing remains behind the existing input-source feature gate.
|
||||
Linux and Windows retain their existing paths, including Windows IME ownership tests.
|
||||
- Forwarded native text still enters `terminal.input()` and xterm's existing ordered
|
||||
`onData` transport. Local PTYs, SSH/remote runtimes, git worktrees, and folder
|
||||
workspaces therefore share the established transport with no host-local assumption.
|
||||
- No Git command, provider-specific review behavior, path handling, or native module
|
||||
baseline changed.
|
||||
|
||||
No push, merge, GitHub/Slack comment, reaction, GUI automation, window focus, or system
|
||||
input-source change was performed.
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
# PR #11293 + PR #11177 semantic integration R2
|
||||
|
||||
## Result
|
||||
|
||||
- R1 product: `c1b329b72f25d8701769be52d0c400c5eaf29510`
|
||||
- R1 evidence: `1d60de49452ebd7cf0b5e3255c5c48d83d96220a`
|
||||
- R2 product: `2f5811d7f6e45c0da925ed4b17f68a558b974497`
|
||||
- Integration base: `a9d8be94df2b750c9d363fc8310567dd7e5504cd`
|
||||
- Preserved contributor commit:
|
||||
`eb76873ce7b9564b248f7317924e6c49b4001b13`
|
||||
(`JeongUk Park <jeongph.dev@gmail.com>`)
|
||||
- Upstream xterm source: `53a98a720ae4a973e384fa2440880d09537132f3`
|
||||
- Runtime/source-map version: `6.1.0-beta.287`
|
||||
- Final xterm patch hash:
|
||||
`642f6920fa1fdcbd740542f81895b092f383e9bfa926aa3967c4197729c5e40b`
|
||||
|
||||
Verifier A's P2 and P3 are fixed in both installed package formats. A restarted
|
||||
transaction now rejects a pre-update end that repeats the retained prior
|
||||
transaction's end observation, so the delayed stale sequence emits `AB`, not
|
||||
`AA`. Deferred composition-position and view work is coalesced by ownership
|
||||
slot and canceled on restart, bounding the 256-transaction same-task burst at
|
||||
three tracked timers instead of 769; all tracked state drains to zero.
|
||||
|
||||
The retained pending composition record remains separate from accepted/settled
|
||||
lifecycle settlement. This preserves immediate restart balance, Korean final
|
||||
consonant reconciliation, Japanese/Chinese ordering, #11052 newline ownership,
|
||||
and #11177 won/backtick forwarding.
|
||||
|
||||
## Parent red and candidate green
|
||||
|
||||
The exact verifier A R1 oracle was read and retained as the control:
|
||||
|
||||
- R1 installed CJS/ESM oracle: 10 passed, 4 failed of 14.
|
||||
- Intended failures: delayed stale end in CJS and ESM, plus the timer bound in
|
||||
CJS and ESM.
|
||||
- R2 full verifier oracle: 14/14 passed.
|
||||
- Tracked focused R2 regression:
|
||||
`terminal-ime-xterm-adversarial.test.ts`, 4/4 passed.
|
||||
- The tracked regression SHA-256 is
|
||||
`69b48b8733b407e8cb2ddd716bbd6dbb87ec12696e950ecaf0e738b67af9512f`.
|
||||
|
||||
The regression uses real installed xterm DOM listeners and separately imports
|
||||
the CJS and ESM bundles. It asserts exact PTY bytes, the four-timer ceiling,
|
||||
timer drainage, and disposal.
|
||||
|
||||
## IME and terminal validation
|
||||
|
||||
| Gate | Result |
|
||||
| ----------------------------------------- | ----------------------------- |
|
||||
| Exact #11011 historical suite | 42/42 passed |
|
||||
| Exact #11177 ten-file historical matrix | 127/127 passed |
|
||||
| #11052 Enter/newline matrix | 3 files, 49/49 passed |
|
||||
| Current combined IME matrix | 14 files, 222/222 passed |
|
||||
| Full verifier A adversarial oracle | 14/14 passed |
|
||||
| Focused tracked P2/P3 CJS+ESM regressions | 4/4 passed |
|
||||
| Broad terminal-pane suite | 201 files, 2,677/2,677 passed |
|
||||
|
||||
The combined matrix covers accepted/settled restarts, stale/duplicate ends,
|
||||
Korean/Japanese/Chinese reconciliation, won/backtick forwarding, Enter and
|
||||
deferred newline behavior, blur, disposal, Linux candidates, and Windows
|
||||
ownership guards.
|
||||
|
||||
## Artifact and lock integrity
|
||||
|
||||
The exact upstream xterm checkout at `53a98a7` compiled with `tsgo`, webpack
|
||||
CJS, and production esbuild ESM. The installed package reconstructed from the
|
||||
committed pnpm patch matches the build/package fixture byte-for-byte for the
|
||||
four artifacts and all modified upstream sources.
|
||||
|
||||
| Artifact | SHA-256 |
|
||||
| ------------------- | ------------------------------------------------------------------ |
|
||||
| `lib/xterm.js` | `c624587a60f1ed497255262c8d894d7e83e7345e6477be1167a0531b56aedfe8` |
|
||||
| `lib/xterm.mjs` | `53e259559fb78c996ad5d3cfd608a8e77285e3d39b4f445719ec2ce4eea1b6e0` |
|
||||
| `lib/xterm.js.map` | `ecb49132c05a0042b563b2434756a985806cb58f024ceecec698f8cec232f509` |
|
||||
| `lib/xterm.mjs.map` | `5413dad5ed07bf66a8d382e0e3da1cb3b41578f34a275e1dc5e47cbc4ae0054a` |
|
||||
|
||||
Both maps contain the exact rebuilt `CompositionHelper.ts` sources content
|
||||
with SHA-256
|
||||
`6cffe8b2aeb6d8279d1315c1c26fe7f270370e66ddc5ef43faf5b73e18c337a1`.
|
||||
All 24 lockfile references use the final raw patch hash. A frozen offline
|
||||
install passed and selected the matching pnpm virtual-store package.
|
||||
|
||||
## Repository gates
|
||||
|
||||
- Node, CLI, and web typechecks passed.
|
||||
- Full lint passed, including native and type-aware code-quality, reliability,
|
||||
max-lines ratchet, localization, and bundled-skill checks.
|
||||
- Changed-code quality and React Doctor reported zero new findings.
|
||||
- Changed TypeScript formatting passed.
|
||||
- Clean-worktree `git diff --check` passed; the generated patch also applies
|
||||
cleanly and reconstructs exact source and artifacts.
|
||||
- Electron Vite production build passed with existing chunking and CSS
|
||||
pseudo-element warnings.
|
||||
- Frozen offline install and native dependency validation passed.
|
||||
|
||||
## Latest main compatibility
|
||||
|
||||
- Fetched `origin/main`:
|
||||
`238d3a1ea167396a1b38275846cea0dd5059b04a`
|
||||
- Conflict-free synthetic merge tree:
|
||||
`8d3178427df19c4e3b4d96287d66a354106b1f0b`
|
||||
- Materialized merge frozen-offline install: passed.
|
||||
- Materialized merge IME matrix: 14 files, 222/222 passed.
|
||||
- Materialized merge Node, CLI, and web typechecks: passed.
|
||||
|
||||
## Platform, transport, security, and performance audit
|
||||
|
||||
The R2 product changes only the xterm patch, its lock hash, and deterministic
|
||||
tests. No Orca PTY, SSH, remote-runtime, folder-workspace, git-worktree,
|
||||
filesystem, provider, IPC, credential, HTML, or network path changed.
|
||||
Forwarded text still enters xterm's ordered `onData` transport for local,
|
||||
remote, and SSH terminals. Linux and Windows routing remains unchanged, while
|
||||
macOS won forwarding retains its existing runtime and modifier gates.
|
||||
|
||||
The new timer slots are constant-space and are cleared on supersession,
|
||||
callback completion, and disposal. The 256-transaction burst emitted all 256
|
||||
Hangul commits exactly once and drained both pending state and the timer set.
|
||||
No dependency version or graph changed.
|
||||
|
||||
## Bounded gaps
|
||||
|
||||
- The host is Darwin, so the native Linux IBus harness was unavailable.
|
||||
- No GUI automation, window focus, or system input-source change was used.
|
||||
- No physical macOS, Linux, Windows, Japanese, Chinese, or Korean IME journey
|
||||
was run.
|
||||
- No live SSH or folder-workspace native-IME journey was run; deterministic
|
||||
transport and ownership suites cover those invariant paths.
|
||||
|
||||
No push, merge, comment, reaction, GUI action, window focus, or input-source
|
||||
mutation was performed.
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
# PR #11293 + PR #11177 semantic integration R3
|
||||
|
||||
## Result
|
||||
|
||||
- R2 evidence HEAD: `6c26dbae39d5c9bd338b6fd3c425a3545d1bf050`
|
||||
- R2 product: `2f5811d7f6e45c0da925ed4b17f68a558b974497`
|
||||
- R3 product: `06e3ec2db829b8b5e4cf40f0018fa896ca267c87`
|
||||
- Integration base: `a9d8be94df2b750c9d363fc8310567dd7e5504cd`
|
||||
- Preserved contributor commit:
|
||||
`eb76873ce7b9564b248f7317924e6c49b4001b13`
|
||||
(`JeongUk Park <jeongph.dev@gmail.com>`)
|
||||
- Upstream xterm source: `53a98a720ae4a973e384fa2440880d09537132f3`
|
||||
- Runtime/source-map version: `6.1.0-beta.287`
|
||||
- Final xterm patch SHA-256:
|
||||
`936fabb7682c1f7d37b9c7f49f3130c4141b6c32974f202e6f03bafaa42350ba`
|
||||
|
||||
R3 replaces R2's data-equality stale-end heuristic with transaction-owned observable
|
||||
progress. Each composition records its starting textarea value and selection. An end
|
||||
belongs to the current transaction only after the transaction has visibly changed that
|
||||
value or selection, an input/deferred position callback has observed that change, or its
|
||||
non-empty data matches the current transaction's own non-empty `compositionupdate`.
|
||||
|
||||
An ambiguous end is held in one tracked, callback-owned timer slot. If native
|
||||
textarea/selection progress lands later in the same task, the end is accepted and
|
||||
finalized; otherwise it remains stale and the transaction waits for its true end. This
|
||||
fixes all three R2 correctness failures:
|
||||
|
||||
- a legitimate repeated no-update `가` commit with visible `가` to `가가` progress emits
|
||||
`가가` with balanced `accepted, settled, accepted, settled`;
|
||||
- a data-less stale end immediately after restart no longer consumes the new transaction,
|
||||
and its true `B` emits final `AB`;
|
||||
- a stale `end('A')` after the restarted `update('B')` but before textarea mutation no
|
||||
longer emits `AA`; the true end emits final `AB`.
|
||||
|
||||
Immediate restart still settles the old lifecycle before accepting the new transaction,
|
||||
while retained pending bytes remain available for Korean final-consonant reconciliation.
|
||||
The Orca composition route does not forward provisional session-end data marked as
|
||||
pending reconciliation.
|
||||
|
||||
## Parent red and candidate green
|
||||
|
||||
The complete verifier A R2 installed-bundle oracle was read and reproduced against the
|
||||
R2 product:
|
||||
|
||||
- R2: 20 passed, 6 failed of 26.
|
||||
- The six expected failures were the repeated no-update false positive, data-less stale
|
||||
end, and stale end after current update, each in installed CJS and ESM.
|
||||
- Verifier A's other 20 CJS/ESM controls remained green, including the R1 stale-end
|
||||
sequence and timer-bound/race cases.
|
||||
|
||||
R3 validation combined verifier A's 26 tests, verifier B's repeated no-update test in CJS
|
||||
and ESM, and the R1 14-test oracle:
|
||||
|
||||
- R3 verifier/R1 oracle matrix: 42/42 passed.
|
||||
- Tracked installed CJS/ESM xterm plus Orca route regressions: 27/27 passed.
|
||||
- Combined focused regression run: 55/55 passed.
|
||||
- Tracked regression SHA-256:
|
||||
`ed375b0ccba820a80385ddb2c0104fb3a37dc285cadfce6f1482483d9cdef086`.
|
||||
|
||||
Tracked regressions cover repeated no-update commits with progress before the end and
|
||||
progress later in the same task, the original data-bearing stale end, the data-less stale
|
||||
end, current-update-before-textarea stale end, same-data with and without progress, the
|
||||
four-timer ceiling, timer drainage, disposal, and forced canceled-callback races.
|
||||
|
||||
Verifier B also reported ten trailing-whitespace additions in the R2 patch. The regenerated
|
||||
R3 patch removes them: `git show --check`, `git diff --check HEAD^ HEAD`, and working-tree
|
||||
`git diff --check` all pass.
|
||||
|
||||
## IME and terminal validation
|
||||
|
||||
| Gate | Result |
|
||||
| ----------------------------------------------------- | ----------------------------- |
|
||||
| Exact #11011 historical suite | 42/42 passed |
|
||||
| Exact #11177 ten-file historical matrix | 127/127 passed |
|
||||
| Exact inherited #11052 Enter/newline cases | 49/49 passed |
|
||||
| Current #11052/route set with R3 reconciliation test | 50/50 passed |
|
||||
| Current combined IME matrix | 14 files, 233/233 passed |
|
||||
| Verifier A R2 + verifier B + R1 oracles | 42/42 passed |
|
||||
| Focused tracked CJS/ESM and route regressions | 27/27 passed |
|
||||
| Broad terminal-pane suite | 201 files, 2,688/2,688 passed |
|
||||
| Verifier B R2 native Linux IBus shared local-PTY path | 60/60 exact sequences passed |
|
||||
|
||||
The combined matrix covers accepted/settled immediate restarts, stale and duplicate ends,
|
||||
Korean final-consonant transfer, Japanese/Chinese ordering, #11052 Enter/deferred newline,
|
||||
#11177 won/backtick forwarding, blur, disposal, Linux candidates, Windows ownership, and
|
||||
bounded timer/callback ownership.
|
||||
|
||||
## Artifact and lock integrity
|
||||
|
||||
The exact upstream xterm checkout at `53a98a7` compiled with TypeScript, webpack CJS, and
|
||||
production esbuild ESM. The committed pnpm patch applies to the matching pristine package;
|
||||
the reconstructed package, installed package, and synthetic-merge installed package match
|
||||
byte-for-byte for all modified source and generated artifacts.
|
||||
|
||||
| Artifact | SHA-256 |
|
||||
| ------------------- | ------------------------------------------------------------------ |
|
||||
| `lib/xterm.js` | `605f406ca62d58e3bdc4367a53b338a0504ef023d4b67d9ce55a0ebe4ef6e575` |
|
||||
| `lib/xterm.mjs` | `3073b72926335549c5b8b3549da091035be489c774e08f2b71780b92a00db6dc` |
|
||||
| `lib/xterm.js.map` | `1e2492bd1fbe8147dd9b6e47c875e4b11478c9f42c5cb24b27b8bb54482d8189` |
|
||||
| `lib/xterm.mjs.map` | `851ad7e138f5a73a7ae74a404269a9a5cf1fd618c7c13db856f0b345499a09bb` |
|
||||
|
||||
Both maps contain the exact rebuilt `CompositionHelper.ts` sources content with SHA-256
|
||||
`a2d5f3252e73c8da40e2fca91281789f8cc98ae16387d679d21b93218b697b4c`.
|
||||
All 24 lockfile references use the final raw patch hash. Frozen-offline installation
|
||||
passed for both the R3 product and the latest-main synthetic merge.
|
||||
|
||||
## Repository gates
|
||||
|
||||
- Node, CLI, and web typechecks passed.
|
||||
- Full lint passed, including native and type-aware code-quality, reliability,
|
||||
max-lines ratchet, localization, and bundled-skill checks.
|
||||
- Changed-code quality, changed React Doctor, and changed React Doctor lint reported zero
|
||||
new findings.
|
||||
- Changed TypeScript formatting and `git diff --check` passed.
|
||||
- Electron Vite production build passed with existing chunking and CSS pseudo-element
|
||||
warnings.
|
||||
- Exact package/source/artifact reconstruction and frozen-offline install passed.
|
||||
|
||||
The full-repository formatter check was run and reported 20 unrelated pre-existing files,
|
||||
including documentation, workflows, reliability config, and renderer files untouched by
|
||||
R3. No unrelated formatting was rewritten; all R3 TypeScript files pass the formatter.
|
||||
|
||||
## Latest main compatibility
|
||||
|
||||
- Fetched `origin/main`:
|
||||
`ef55429f3d2ce3fbbcc542e4dcc8a6b36e464455`
|
||||
- Conflict-free synthetic merge tree:
|
||||
`5613493fa46eaedabcc86c7041bc2369a2031d38`
|
||||
- Materialized merge frozen-offline install: passed.
|
||||
- Materialized merge IME matrix: 14 files, 233/233 passed.
|
||||
- Materialized merge Node, CLI, and web typechecks: passed.
|
||||
- Materialized merge CJS, ESM, and both maps match the R3 hashes above.
|
||||
|
||||
## Security, performance, platform, and workspace audit
|
||||
|
||||
The dependency graph and versions are unchanged; `pnpm-lock.yaml` changes only the xterm
|
||||
patch hash references. The production dependency audit reports no known vulnerabilities.
|
||||
The all-dependency audit was also run and reported five existing development-tool
|
||||
advisories: one moderate and four high, through `shadcn > postcss` and
|
||||
`electron-builder > minimatch > brace-expansion`. R3 adds no package and does not alter
|
||||
those dependency paths.
|
||||
|
||||
R3 changes no PTY, SSH, remote-runtime, folder-workspace, git-worktree, filesystem,
|
||||
provider, Git command, IPC, credential, HTML, or network path. Forwarded bytes still use
|
||||
xterm's ordered `onData` transport for local, SSH, and remote terminals. Linux and Windows
|
||||
routing is unchanged; macOS won forwarding retains the existing runtime/modifier gates.
|
||||
|
||||
Deferred composition work remains constant-space: position, view, end, and finalizer work
|
||||
each owns at most one tracked slot, superseded callbacks cannot clear newer slots, and all
|
||||
slots are removed on callback completion, restart, cancellation, blur, or disposal. The
|
||||
4,096-transaction verifier controls and tracked 256-transaction burst emit exact ordered
|
||||
bytes and fully drain tracked state.
|
||||
|
||||
## Bounded gaps
|
||||
|
||||
- The host is Darwin, so the native Linux IBus harness was unavailable.
|
||||
- Verifier B independently ran the exact R2 package on isolated Debian/Xvfb/IBus and
|
||||
passed 60/60 local-PTY byte sequences; R3 changes only end ownership classification and
|
||||
retains that transport path.
|
||||
- No GUI automation, window focus, or system input-source change was used.
|
||||
- No physical macOS, Linux, Windows, Japanese, Chinese, or Korean IME journey was run.
|
||||
- No live SSH or folder-workspace native-IME journey was run; deterministic transport and
|
||||
ownership suites cover those invariant paths.
|
||||
- The repository-wide formatter and dependency audit retain the unrelated baseline
|
||||
findings described above.
|
||||
|
||||
No push, merge, comment, reaction, GUI action, window focus, or input-source mutation was
|
||||
performed.
|
||||
|
|
@ -0,0 +1,534 @@
|
|||
import { randomUUID } from 'node:crypto'
|
||||
import { rmSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { CDPSession, Page, TestInfo } from '@stablyai/playwright-test'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
focusActiveTerminalInput,
|
||||
getTerminalContent,
|
||||
sendToTerminal,
|
||||
waitForActivePanePtyId,
|
||||
waitForActiveTerminalManager,
|
||||
waitForTerminalOutput
|
||||
} from './helpers/terminal'
|
||||
|
||||
// Repro for the Shift/Ctrl+Enter Hangul commit race: macOS delivers a
|
||||
// committing Enter chord TWICE — first as an IME keydown (keyCode 229, isComposing=true),
|
||||
// then ~2 ms after compositionend as a re-dispatched plain keydown
|
||||
// (keyCode 13, isComposing=false). The window-level shortcut handler must send
|
||||
// exactly one newline, and only after the committed syllable has flushed.
|
||||
// Deferring only the composing keydown is not enough: the re-dispatch would
|
||||
// still send its newline immediately (ahead of the glyph) and the deferred
|
||||
// send would then double it.
|
||||
|
||||
const PROMPT = '› '
|
||||
|
||||
function stripTerminalControls(value: string): string {
|
||||
let output = ''
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index)
|
||||
if (code === 0x1b) {
|
||||
const next = value[index + 1]
|
||||
if (next === ']') {
|
||||
index += 2
|
||||
while (index < value.length) {
|
||||
const current = value.charCodeAt(index)
|
||||
if (current === 0x07) {
|
||||
break
|
||||
}
|
||||
if (current === 0x1b && value[index + 1] === '\\') {
|
||||
index += 1
|
||||
break
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (next === '[') {
|
||||
index += 2
|
||||
while (index < value.length && value.charCodeAt(index) < 0x40) {
|
||||
index += 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ((code >= 0 && code <= 0x08) || (code >= 0x0b && code <= 0x1f) || code === 0x7f) {
|
||||
continue
|
||||
}
|
||||
output += value[index]
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
function terminalImeHarnessScript(runId: string): string {
|
||||
return `
|
||||
const runId = ${JSON.stringify(runId)}
|
||||
let model = ''
|
||||
let received = ''
|
||||
|
||||
function handleData(data) {
|
||||
received += data
|
||||
for (const ch of data) {
|
||||
if (ch === '\\u0003') {
|
||||
process.exit(0)
|
||||
}
|
||||
if (ch === '\\r' || ch === '\\n') {
|
||||
process.stdout.write('\\r\\x1b[2K[SUBMITTED_JSON_' + runId + ']' + JSON.stringify(model) + '\\n')
|
||||
model = ''
|
||||
continue
|
||||
}
|
||||
if (ch === '\\u007f' || ch === '\\b') {
|
||||
model = Array.from(model).slice(0, -1).join('')
|
||||
continue
|
||||
}
|
||||
model += ch
|
||||
}
|
||||
process.stdout.write('\\r\\x1b[2K[RECEIVED_JSON_' + runId + ']' + JSON.stringify(received) + '\\n')
|
||||
process.stdout.write('\\r\\x1b[2K${PROMPT}' + model.replace(/\\x1b/g, '<ESC>'))
|
||||
}
|
||||
|
||||
if (process.stdin.isTTY) process.stdin.setRawMode(true)
|
||||
process.stdin.setEncoding('utf8')
|
||||
process.stdout.write('IME_HARNESS_READY_' + runId + '\\n')
|
||||
process.stdout.write('${PROMPT}')
|
||||
process.stdin.on('data', handleData)
|
||||
`
|
||||
}
|
||||
|
||||
async function readSubmitted(page: Page): Promise<string[]> {
|
||||
const content = stripTerminalControls(await getTerminalContent(page, 20_000))
|
||||
const matches = [...content.matchAll(/\[SUBMITTED_JSON_[^\]]+\]("[\s\S]*?")/g)]
|
||||
return matches
|
||||
.map((match) => {
|
||||
try {
|
||||
return JSON.parse(match[1] ?? '""') as string
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter((value): value is string => value !== null)
|
||||
}
|
||||
|
||||
async function readReceived(page: Page): Promise<string | null> {
|
||||
const content = stripTerminalControls(await getTerminalContent(page, 20_000))
|
||||
const matches = [...content.matchAll(/\[RECEIVED_JSON_[^\]]+\]("[\s\S]*?")/g)]
|
||||
const encoded = matches.at(-1)?.[1]
|
||||
if (!encoded) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return JSON.parse(encoded) as string
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
type ImeKeyEvent = {
|
||||
type: string
|
||||
key: string
|
||||
code: string
|
||||
keyCode: number
|
||||
isComposing: boolean
|
||||
repeat: boolean
|
||||
shiftKey: boolean
|
||||
ctrlKey: boolean
|
||||
timeStamp: number
|
||||
}
|
||||
|
||||
async function installImeKeyEventLog(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const target = window as unknown as { __imeKeyEvents: ImeKeyEvent[] }
|
||||
target.__imeKeyEvents = []
|
||||
const record = (event: KeyboardEvent): void => {
|
||||
target.__imeKeyEvents.push({
|
||||
type: event.type,
|
||||
key: event.key,
|
||||
code: event.code,
|
||||
keyCode: event.keyCode,
|
||||
isComposing: event.isComposing,
|
||||
repeat: event.repeat,
|
||||
shiftKey: event.shiftKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
timeStamp: event.timeStamp
|
||||
})
|
||||
}
|
||||
window.addEventListener('keydown', record, true)
|
||||
window.addEventListener('keyup', record, true)
|
||||
})
|
||||
}
|
||||
|
||||
async function readImeKeyEventLog(page: Page): Promise<ImeKeyEvent[]> {
|
||||
return page.evaluate(
|
||||
() => (window as unknown as { __imeKeyEvents?: ImeKeyEvent[] }).__imeKeyEvents ?? []
|
||||
)
|
||||
}
|
||||
|
||||
async function attachEvidence(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
||||
const evidence = {
|
||||
keyEvents: await readImeKeyEventLog(page),
|
||||
received: await readReceived(page),
|
||||
terminal: await getTerminalContent(page, 20_000),
|
||||
submitted: await readSubmitted(page)
|
||||
}
|
||||
await testInfo.attach(`${name}.json`, {
|
||||
body: `${JSON.stringify(evidence, null, 2)}\n`,
|
||||
contentType: 'application/json'
|
||||
})
|
||||
}
|
||||
|
||||
async function dispatchHangulProcessKey(
|
||||
session: CDPSession,
|
||||
key: string,
|
||||
code: string
|
||||
): Promise<void> {
|
||||
// Why: macOS Hangul jamo keydowns arrive as IME Process keys (keyCode 229)
|
||||
// with the jamo in `key`; the release carries the physical keyCode.
|
||||
await session.send('Input.dispatchKeyEvent', {
|
||||
type: 'rawKeyDown',
|
||||
key,
|
||||
code,
|
||||
windowsVirtualKeyCode: 229,
|
||||
nativeVirtualKeyCode: 229,
|
||||
text: '',
|
||||
unmodifiedText: ''
|
||||
})
|
||||
await session.send('Input.dispatchKeyEvent', {
|
||||
type: 'keyUp',
|
||||
key,
|
||||
code,
|
||||
windowsVirtualKeyCode: 229,
|
||||
nativeVirtualKeyCode: 229,
|
||||
text: '',
|
||||
unmodifiedText: ''
|
||||
})
|
||||
}
|
||||
|
||||
async function composeHangulSyllable(session: CDPSession, page: Page): Promise<void> {
|
||||
await dispatchHangulProcessKey(session, 'ㅎ', 'KeyG')
|
||||
await session.send('Input.imeSetComposition', { text: 'ㅎ', selectionStart: 1, selectionEnd: 1 })
|
||||
await page.waitForTimeout(60)
|
||||
await dispatchHangulProcessKey(session, 'ㅏ', 'KeyK')
|
||||
await session.send('Input.imeSetComposition', { text: '하', selectionStart: 1, selectionEnd: 1 })
|
||||
await page.waitForTimeout(60)
|
||||
}
|
||||
|
||||
async function commitSyllableAndSpace(session: CDPSession, page: Page): Promise<void> {
|
||||
await session.send('Input.insertText', { text: '하' })
|
||||
await page.waitForTimeout(60)
|
||||
await session.send('Input.dispatchKeyEvent', {
|
||||
type: 'keyDown',
|
||||
key: ' ',
|
||||
code: 'Space',
|
||||
windowsVirtualKeyCode: 32,
|
||||
nativeVirtualKeyCode: 32,
|
||||
text: ' ',
|
||||
unmodifiedText: ' '
|
||||
})
|
||||
await session.send('Input.dispatchKeyEvent', {
|
||||
type: 'keyUp',
|
||||
key: ' ',
|
||||
code: 'Space',
|
||||
windowsVirtualKeyCode: 32,
|
||||
nativeVirtualKeyCode: 32
|
||||
})
|
||||
await page.waitForTimeout(60)
|
||||
}
|
||||
|
||||
/**
|
||||
* The committing Enter chord as recorded from the real macOS 2-set Korean IME:
|
||||
* IME keydown (229) -> commit -> re-dispatched plain keydown (13) -> keyup,
|
||||
* delivered in one un-awaited burst. The real IME delivers all of this within
|
||||
* the same native key-processing turn, ahead of xterm's setTimeout(0) glyph
|
||||
* flush; awaiting each CDP round-trip would let the flush win and hide the
|
||||
* race.
|
||||
*/
|
||||
async function dispatchCommittingEnterChord(
|
||||
session: CDPSession,
|
||||
page: Page,
|
||||
modifiers: number,
|
||||
redispatchedModifiers: number,
|
||||
redispatchAfterKeyup: boolean,
|
||||
redispatchTimestampOffset = 0
|
||||
): Promise<void> {
|
||||
const timestamp = Date.now() / 1000
|
||||
const composingKeydown = session.send('Input.dispatchKeyEvent', {
|
||||
type: 'rawKeyDown',
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
modifiers,
|
||||
timestamp,
|
||||
windowsVirtualKeyCode: 229,
|
||||
nativeVirtualKeyCode: 229,
|
||||
text: '',
|
||||
unmodifiedText: ''
|
||||
})
|
||||
const commit = session.send('Input.insertText', { text: '하' })
|
||||
const redispatch = () =>
|
||||
session.send('Input.dispatchKeyEvent', {
|
||||
type: 'rawKeyDown',
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
modifiers: redispatchedModifiers,
|
||||
timestamp: timestamp + redispatchTimestampOffset,
|
||||
windowsVirtualKeyCode: 13,
|
||||
nativeVirtualKeyCode: 13,
|
||||
text: '',
|
||||
unmodifiedText: ''
|
||||
})
|
||||
const balancingKeyup = () =>
|
||||
session.send('Input.dispatchKeyEvent', {
|
||||
type: 'keyUp',
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
modifiers: redispatchedModifiers,
|
||||
timestamp: timestamp + redispatchTimestampOffset,
|
||||
windowsVirtualKeyCode: 13,
|
||||
nativeVirtualKeyCode: 13
|
||||
})
|
||||
|
||||
if (!redispatchAfterKeyup) {
|
||||
await Promise.all([composingKeydown, commit, redispatch(), balancingKeyup()])
|
||||
return
|
||||
}
|
||||
await Promise.all([composingKeydown, commit, balancingKeyup()])
|
||||
await page.waitForTimeout(80)
|
||||
await redispatch()
|
||||
}
|
||||
|
||||
type HeldModifier = {
|
||||
key: 'Shift' | 'Control'
|
||||
code: 'ShiftLeft' | 'ControlLeft'
|
||||
keyCode: 16 | 17
|
||||
modifiers: number
|
||||
}
|
||||
|
||||
async function dispatchHeldModifier(
|
||||
session: CDPSession,
|
||||
modifier: HeldModifier,
|
||||
type: 'rawKeyDown' | 'keyUp'
|
||||
): Promise<void> {
|
||||
await session.send('Input.dispatchKeyEvent', {
|
||||
type,
|
||||
key: modifier.key,
|
||||
code: modifier.code,
|
||||
modifiers: type === 'rawKeyDown' ? modifier.modifiers : 0,
|
||||
windowsVirtualKeyCode: modifier.keyCode,
|
||||
nativeVirtualKeyCode: modifier.keyCode
|
||||
})
|
||||
}
|
||||
|
||||
async function dispatchPlainEnter(session: CDPSession): Promise<void> {
|
||||
await session.send('Input.dispatchKeyEvent', {
|
||||
type: 'rawKeyDown',
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
windowsVirtualKeyCode: 13,
|
||||
nativeVirtualKeyCode: 13
|
||||
})
|
||||
await session.send('Input.dispatchKeyEvent', {
|
||||
type: 'keyUp',
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
windowsVirtualKeyCode: 13,
|
||||
nativeVirtualKeyCode: 13
|
||||
})
|
||||
}
|
||||
|
||||
async function readPromptLine(page: Page): Promise<string> {
|
||||
const content = stripTerminalControls(await getTerminalContent(page, 20_000))
|
||||
const promptIndex = content.lastIndexOf(PROMPT)
|
||||
if (promptIndex < 0) {
|
||||
return ''
|
||||
}
|
||||
return (content.slice(promptIndex + PROMPT.length).split(/\r?\n/)[0] ?? '').trimEnd()
|
||||
}
|
||||
|
||||
type CommittingEnterChordCase = {
|
||||
name: string
|
||||
slug: string
|
||||
modifiers: number
|
||||
redispatchedModifiers?: number
|
||||
redispatchTimestampOffset?: number
|
||||
preHeldModifier?: HeldModifier
|
||||
windowsOnly?: boolean
|
||||
assertOutcome: (page: Page) => Promise<void>
|
||||
expectedAfterPlainEnter: {
|
||||
received: string
|
||||
submitted: string[]
|
||||
}
|
||||
}
|
||||
|
||||
async function assertShiftOutcome(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(() => readReceived(page), {
|
||||
timeout: 10_000,
|
||||
message: 'PTY bytes must contain committed Hangul before exactly one Shift+Enter chord'
|
||||
})
|
||||
.toBe('하 하 하\u001b\r')
|
||||
await expect
|
||||
.poll(async () => (await readSubmitted(page)).at(-1) ?? null, {
|
||||
timeout: 10_000,
|
||||
message: 'submitted line must contain the full text with the trailing syllable inline'
|
||||
})
|
||||
.toBe('하 하 하\u001b')
|
||||
await page.waitForTimeout(500)
|
||||
expect(await readSubmitted(page), 'Shift+Enter must produce exactly one newline').toEqual([
|
||||
'하 하 하\u001b'
|
||||
])
|
||||
}
|
||||
|
||||
async function assertCtrlOutcome(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(() => readReceived(page), {
|
||||
timeout: 10_000,
|
||||
message: 'PTY bytes must contain committed Hangul before exactly one Ctrl+Enter chord'
|
||||
})
|
||||
.toBe('하 하 하\u001b[13;5u')
|
||||
await expect
|
||||
.poll(() => readPromptLine(page), {
|
||||
timeout: 10_000,
|
||||
message: 'prompt must show the committed syllables followed by exactly one CSI-u chord'
|
||||
})
|
||||
.toBe('하 하 하<ESC>[13;5u')
|
||||
await page.waitForTimeout(500)
|
||||
expect(await readPromptLine(page), 'Ctrl+Enter must produce exactly one CSI-u chord').toBe(
|
||||
'하 하 하<ESC>[13;5u'
|
||||
)
|
||||
expect(await readSubmitted(page), 'CSI-u must not submit the line').toEqual([])
|
||||
}
|
||||
|
||||
const COMMITTING_ENTER_CHORDS: CommittingEnterChordCase[] = [
|
||||
{
|
||||
name: 'Shift+Enter',
|
||||
slug: 'shift-enter',
|
||||
modifiers: 8,
|
||||
assertOutcome: assertShiftOutcome,
|
||||
expectedAfterPlainEnter: {
|
||||
received: '하 하 하\u001b\r\r',
|
||||
submitted: ['하 하 하\u001b', '']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Ctrl+Enter',
|
||||
slug: 'ctrl-enter',
|
||||
modifiers: 2,
|
||||
assertOutcome: assertCtrlOutcome,
|
||||
expectedAfterPlainEnter: {
|
||||
received: '하 하 하\u001b[13;5u\r',
|
||||
submitted: ['하 하 하\u001b[13;5u']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Shift+Enter with modifier-lost redispatch',
|
||||
slug: 'shift-enter-bare-redispatch',
|
||||
modifiers: 8,
|
||||
redispatchedModifiers: 0,
|
||||
assertOutcome: assertShiftOutcome,
|
||||
expectedAfterPlainEnter: {
|
||||
received: '하 하 하\u001b\r\r',
|
||||
submitted: ['하 하 하\u001b', '']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'pre-held Shift+Enter with modifier-lost redispatch',
|
||||
slug: 'pre-held-shift-enter-bare-redispatch',
|
||||
modifiers: 8,
|
||||
redispatchedModifiers: 0,
|
||||
redispatchTimestampOffset: 0.01,
|
||||
preHeldModifier: { key: 'Shift', code: 'ShiftLeft', keyCode: 16, modifiers: 8 },
|
||||
windowsOnly: true,
|
||||
assertOutcome: assertShiftOutcome,
|
||||
expectedAfterPlainEnter: {
|
||||
received: '하 하 하\u001b\r\r',
|
||||
submitted: ['하 하 하\u001b', '']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'pre-held Ctrl+Enter with modifier-lost redispatch',
|
||||
slug: 'pre-held-ctrl-enter-bare-redispatch',
|
||||
modifiers: 2,
|
||||
redispatchedModifiers: 0,
|
||||
redispatchTimestampOffset: 0.01,
|
||||
preHeldModifier: { key: 'Control', code: 'ControlLeft', keyCode: 17, modifiers: 2 },
|
||||
windowsOnly: true,
|
||||
assertOutcome: assertCtrlOutcome,
|
||||
expectedAfterPlainEnter: {
|
||||
received: '하 하 하\u001b[13;5u\r',
|
||||
submitted: ['하 하 하\u001b[13;5u']
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
test.describe('Korean IME terminal committing Enter chords', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
for (const chord of COMMITTING_ENTER_CHORDS) {
|
||||
for (const redispatchAfterKeyup of [false, true]) {
|
||||
const order = redispatchAfterKeyup ? 'keyup-before-redispatch' : 'redispatch-before-keyup'
|
||||
test(`${chord.name} sends once with ${order}`, async ({
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}, testInfo) => {
|
||||
test.skip(chord.windowsOnly && process.platform !== 'win32', 'Windows IME ownership')
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage)
|
||||
const runId = randomUUID()
|
||||
const scriptPath = path.join(testRepoPath, `.orca-korean-ime-harness-${runId}.cjs`)
|
||||
const session = await orcaPage.context().newCDPSession(orcaPage)
|
||||
|
||||
try {
|
||||
writeFileSync(scriptPath, terminalImeHarnessScript(runId))
|
||||
await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`)
|
||||
await waitForTerminalOutput(orcaPage, `IME_HARNESS_READY_${runId}`, 10_000, 20_000)
|
||||
await focusActiveTerminalInput(orcaPage)
|
||||
await installImeKeyEventLog(orcaPage)
|
||||
|
||||
// 하 하 하 with the first two syllables committed by Space and the last
|
||||
// one left composing, so the Enter chord is the committing keystroke.
|
||||
await composeHangulSyllable(session, orcaPage)
|
||||
await commitSyllableAndSpace(session, orcaPage)
|
||||
await composeHangulSyllable(session, orcaPage)
|
||||
await commitSyllableAndSpace(session, orcaPage)
|
||||
if (chord.preHeldModifier) {
|
||||
await dispatchHeldModifier(session, chord.preHeldModifier, 'rawKeyDown')
|
||||
}
|
||||
await composeHangulSyllable(session, orcaPage)
|
||||
await dispatchCommittingEnterChord(
|
||||
session,
|
||||
orcaPage,
|
||||
chord.modifiers,
|
||||
chord.redispatchedModifiers ?? chord.modifiers,
|
||||
redispatchAfterKeyup,
|
||||
chord.redispatchTimestampOffset
|
||||
)
|
||||
if (chord.preHeldModifier) {
|
||||
await dispatchHeldModifier(session, chord.preHeldModifier, 'keyUp')
|
||||
}
|
||||
|
||||
await chord.assertOutcome(orcaPage)
|
||||
await dispatchPlainEnter(session)
|
||||
await expect
|
||||
.poll(() => readReceived(orcaPage), {
|
||||
timeout: 10_000,
|
||||
message: 'the next physical Enter must not be consumed by stale IME state'
|
||||
})
|
||||
.toBe(chord.expectedAfterPlainEnter.received)
|
||||
expect(await readSubmitted(orcaPage)).toEqual(chord.expectedAfterPlainEnter.submitted)
|
||||
await attachEvidence(orcaPage, testInfo, `korean-${chord.slug}-${order}-commit`)
|
||||
} finally {
|
||||
await attachEvidence(orcaPage, testInfo, `korean-${chord.slug}-${order}-final`).catch(
|
||||
() => undefined
|
||||
)
|
||||
await session.detach().catch(() => undefined)
|
||||
await sendToTerminal(orcaPage, ptyId, '\x03').catch(() => undefined)
|
||||
rmSync(scriptPath, { force: true })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
import { execFileSync } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Page, TestInfo } from '@stablyai/playwright-test'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
focusActiveTerminalInput,
|
||||
sendToTerminal,
|
||||
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 DEFAULT_REPETITIONS = 30
|
||||
const MAX_REPETITIONS = 30
|
||||
const DEFAULT_KEY_DELAY_MS = 1
|
||||
const MAX_KEY_DELAY_MS = 100
|
||||
const NATIVE_COMMAND_TIMEOUT_MS = 10_000
|
||||
|
||||
test.use({
|
||||
orcaAppExtraEnv: {
|
||||
GTK_IM_MODULE: 'ibus',
|
||||
IBUS_ENABLE_SYNC_MODE: '1',
|
||||
QT_IM_MODULE: 'ibus',
|
||||
XMODIFIERS: '@im=ibus'
|
||||
}
|
||||
})
|
||||
|
||||
function nativeRepetitions(): number {
|
||||
const parsed = Number(process.env.ORCA_E2E_NATIVE_IBUS_REPETITIONS ?? DEFAULT_REPETITIONS)
|
||||
return Number.isInteger(parsed) && parsed > 0
|
||||
? Math.min(parsed, MAX_REPETITIONS)
|
||||
: DEFAULT_REPETITIONS
|
||||
}
|
||||
|
||||
function nativeKeyDelayMs(): number {
|
||||
const parsed = Number(process.env.ORCA_E2E_NATIVE_IBUS_KEY_DELAY_MS ?? DEFAULT_KEY_DELAY_MS)
|
||||
return Number.isInteger(parsed) && parsed >= 0
|
||||
? Math.min(parsed, MAX_KEY_DELAY_MS)
|
||||
: DEFAULT_KEY_DELAY_MS
|
||||
}
|
||||
|
||||
function runXdotool(...args: string[]): void {
|
||||
execFileSync('xdotool', args, { stdio: 'pipe', timeout: NATIVE_COMMAND_TIMEOUT_MS })
|
||||
}
|
||||
|
||||
async function focusNativeTerminalWindow(page: Page): Promise<string> {
|
||||
await focusActiveTerminalInput(page)
|
||||
const title = `ORCA_NATIVE_IBUS_${randomUUID()}`
|
||||
await page.evaluate((nextTitle) => {
|
||||
document.title = nextTitle
|
||||
}, title)
|
||||
await expect.poll(() => page.title(), { timeout: 5_000 }).toBe(title)
|
||||
|
||||
runXdotool('search', '--onlyvisible', '--name', title, 'windowfocus', '--sync')
|
||||
execFileSync('ibus', ['engine', 'hangul'], {
|
||||
stdio: 'pipe',
|
||||
timeout: NATIVE_COMMAND_TIMEOUT_MS
|
||||
})
|
||||
const engine = execFileSync('ibus', ['engine'], {
|
||||
encoding: 'utf8',
|
||||
timeout: NATIVE_COMMAND_TIMEOUT_MS
|
||||
}).trim()
|
||||
expect(engine).toBe('hangul')
|
||||
return title
|
||||
}
|
||||
|
||||
function typeExactByteSequence(repetitions: number): void {
|
||||
const delay = String(nativeKeyDelayMs())
|
||||
for (let index = 0; index < repetitions; index += 1) {
|
||||
runXdotool('type', '--delay', delay, '--clearmodifiers', 'gks')
|
||||
runXdotool('key', 'Hangul')
|
||||
runXdotool('type', '--delay', delay, 'abc')
|
||||
runXdotool('key', 'Hangul')
|
||||
runXdotool('type', '--delay', delay, 'rmf')
|
||||
runXdotool('key', 'Return')
|
||||
}
|
||||
}
|
||||
|
||||
function typeSentenceSequence(repetitions: number): void {
|
||||
const delay = String(nativeKeyDelayMs())
|
||||
for (let index = 0; index < repetitions; index += 1) {
|
||||
runXdotool(
|
||||
'type',
|
||||
'--delay',
|
||||
delay,
|
||||
'--clearmodifiers',
|
||||
'xptmxmfmf gkrh dlTsmsep duwjsgl rmfjsp'
|
||||
)
|
||||
runXdotool('key', 'Return')
|
||||
}
|
||||
}
|
||||
|
||||
async function runNativeIbusScenario(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
testRepoPath: string,
|
||||
expectedText: string,
|
||||
driveInput: (repetitions: number) => void
|
||||
): Promise<void> {
|
||||
await waitForSessionReady(page)
|
||||
await waitForActiveWorktree(page)
|
||||
await ensureTerminalVisible(page)
|
||||
await waitForActiveTerminalManager(page, 30_000)
|
||||
|
||||
const repetitions = nativeRepetitions()
|
||||
const ptyId = await waitForActivePanePtyId(page)
|
||||
const reader = createTerminalImeByteReader(testRepoPath, repetitions)
|
||||
let completed = false
|
||||
let receivedBytes: string[] = []
|
||||
try {
|
||||
await startTerminalImeByteReader(page, ptyId, reader)
|
||||
await focusNativeTerminalWindow(page)
|
||||
await installTerminalImeBoundaryProbe(page)
|
||||
driveInput(repetitions)
|
||||
|
||||
receivedBytes = await waitForTerminalImeBytes(page, reader, 30_000)
|
||||
const trace = await readTerminalImeBoundaryTrace(page)
|
||||
expect(trace.dom.some((event) => event.type === 'compositionstart')).toBe(true)
|
||||
expect(
|
||||
trace.dom.some(
|
||||
(event) =>
|
||||
(event.type === 'compositionupdate' ||
|
||||
(event.type === 'input' && event.inputType === 'insertText')) &&
|
||||
/[\uac00-\ud7af]/.test(event.data ?? '')
|
||||
)
|
||||
).toBe(true)
|
||||
|
||||
const expectedBytes = Buffer.from(`${expectedText}\n`).toString('hex')
|
||||
expect(receivedBytes).toEqual(Array.from({ length: repetitions }, () => expectedBytes))
|
||||
|
||||
expect(trace.onData.join('')).toBe(`${expectedText}\r`.repeat(repetitions))
|
||||
completed = true
|
||||
} finally {
|
||||
await attachTerminalImeBoundaryEvidence(page, testInfo, 'native-ibus-boundaries', {
|
||||
display: process.env.DISPLAY,
|
||||
expectedText,
|
||||
keyDelayMs: nativeKeyDelayMs(),
|
||||
receivedBytes,
|
||||
repetitions
|
||||
}).catch(() => undefined)
|
||||
await disposeTerminalImeBoundaryProbe(page).catch(() => undefined)
|
||||
if (!completed) {
|
||||
await sendToTerminal(page, ptyId, '\x03').catch(() => undefined)
|
||||
}
|
||||
removeTerminalImeByteReader(reader)
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Native IBus Hangul terminal input @headful', () => {
|
||||
test.skip(
|
||||
process.env.ORCA_E2E_NATIVE_IBUS_HANGUL !== '1',
|
||||
'Run through config/scripts/run-terminal-ibus-hangul-e2e.mjs'
|
||||
)
|
||||
|
||||
test('forwards the issue exact-byte sequence without loss or duplication', async ({
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}, testInfo) => {
|
||||
await runNativeIbusScenario(orcaPage, testInfo, testRepoPath, '한abc글', typeExactByteSequence)
|
||||
})
|
||||
|
||||
test('forwards the issue sentence stress sequence without leaked ASCII', async ({
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}, testInfo) => {
|
||||
await runNativeIbusScenario(
|
||||
orcaPage,
|
||||
testInfo,
|
||||
testRepoPath,
|
||||
'테스트를 하고 있는데 여전히 그러네',
|
||||
typeSentenceSequence
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { Page, TestInfo } from '@stablyai/playwright-test'
|
||||
|
||||
export type TerminalImeDomEvent = {
|
||||
type: string
|
||||
data: string | null
|
||||
inputType: string | null
|
||||
key: string | null
|
||||
code: string | null
|
||||
keyCode: number | null
|
||||
isComposing: boolean | null
|
||||
selectionEnd: number | null
|
||||
selectionStart: number | null
|
||||
value: string
|
||||
}
|
||||
|
||||
export type TerminalImeBoundaryTrace = {
|
||||
dom: TerminalImeDomEvent[]
|
||||
onData: string[]
|
||||
}
|
||||
|
||||
type TerminalImeProbeWindow = Window & {
|
||||
__terminalImeBoundaryProbe?: TerminalImeBoundaryTrace & { dispose: () => void }
|
||||
}
|
||||
|
||||
export async function installTerminalImeBoundaryProbe(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const targetWindow = window as TerminalImeProbeWindow
|
||||
targetWindow.__terminalImeBoundaryProbe?.dispose()
|
||||
|
||||
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
|
||||
const textarea = pane?.container.querySelector<HTMLTextAreaElement>('.xterm-helper-textarea')
|
||||
if (!pane || !textarea) {
|
||||
throw new Error('No active terminal textarea for IME boundary probe')
|
||||
}
|
||||
|
||||
const dom: TerminalImeDomEvent[] = []
|
||||
const onData: string[] = []
|
||||
const record = (event: Event): void => {
|
||||
const input = event instanceof InputEvent ? event : null
|
||||
const composition = event instanceof CompositionEvent ? event : null
|
||||
const keyboard = event instanceof KeyboardEvent ? event : null
|
||||
dom.push({
|
||||
type: event.type,
|
||||
data: input?.data ?? composition?.data ?? null,
|
||||
inputType: input?.inputType ?? null,
|
||||
key: keyboard?.key ?? null,
|
||||
code: keyboard?.code ?? null,
|
||||
keyCode: keyboard?.keyCode ?? null,
|
||||
isComposing: keyboard?.isComposing ?? input?.isComposing ?? null,
|
||||
selectionEnd: textarea.selectionEnd,
|
||||
selectionStart: textarea.selectionStart,
|
||||
value: textarea.value
|
||||
})
|
||||
}
|
||||
const eventTypes = [
|
||||
'compositionstart',
|
||||
'compositionupdate',
|
||||
'compositionend',
|
||||
'beforeinput',
|
||||
'input',
|
||||
'keydown',
|
||||
'keypress',
|
||||
'keyup'
|
||||
]
|
||||
for (const eventType of eventTypes) {
|
||||
textarea.addEventListener(eventType, record, true)
|
||||
}
|
||||
const onDataDisposable = pane.terminal.onData((data) => onData.push(data))
|
||||
targetWindow.__terminalImeBoundaryProbe = {
|
||||
dom,
|
||||
onData,
|
||||
dispose: () => {
|
||||
for (const eventType of eventTypes) {
|
||||
textarea.removeEventListener(eventType, record, true)
|
||||
}
|
||||
onDataDisposable.dispose()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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: [] }
|
||||
})
|
||||
}
|
||||
|
||||
export async function disposeTerminalImeBoundaryProbe(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const targetWindow = window as TerminalImeProbeWindow
|
||||
targetWindow.__terminalImeBoundaryProbe?.dispose()
|
||||
delete targetWindow.__terminalImeBoundaryProbe
|
||||
})
|
||||
}
|
||||
|
||||
export async function attachTerminalImeBoundaryEvidence(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
extra: Record<string, unknown> = {}
|
||||
): Promise<void> {
|
||||
const body = `${JSON.stringify(
|
||||
{ ...extra, trace: await readTerminalImeBoundaryTrace(page) },
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
await testInfo.attach(`${name}.json`, {
|
||||
body,
|
||||
contentType: 'application/json'
|
||||
})
|
||||
const evidenceDir = path.join(process.cwd(), 'test-results', 'terminal-ime-evidence')
|
||||
const title = testInfo.title
|
||||
.replaceAll(/[^a-z0-9]+/gi, '-')
|
||||
.replaceAll(/^-|-$/g, '')
|
||||
.toLowerCase()
|
||||
mkdirSync(evidenceDir, { recursive: true })
|
||||
writeFileSync(path.join(evidenceDir, `${name}-${title}.json`), body)
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import { randomUUID } from 'node:crypto'
|
||||
import { rmSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { expect } from '@stablyai/playwright-test'
|
||||
import { getTerminalContent, sendToTerminal, waitForTerminalOutput } from './helpers/terminal'
|
||||
|
||||
export type TerminalImeByteReader = {
|
||||
expectedLineCount: number
|
||||
readyMarker: string
|
||||
resultPrefix: string
|
||||
scriptPath: string
|
||||
}
|
||||
|
||||
export function createTerminalImeByteReader(
|
||||
testRepoPath: string,
|
||||
expectedLineCount: number
|
||||
): TerminalImeByteReader {
|
||||
const runId = randomUUID().replaceAll('-', '')
|
||||
const readyMarker = `ORCA_IME_READER_READY_${runId}`
|
||||
const resultPrefix = `ORCA_IME_BYTES_${runId}`
|
||||
const scriptPath = path.join(testRepoPath, `.orca-ime-byte-reader-${runId}.cjs`)
|
||||
const source = `
|
||||
const expectedLineCount = ${expectedLineCount}
|
||||
const readyMarker = ${JSON.stringify(readyMarker)}
|
||||
const resultPrefix = ${JSON.stringify(resultPrefix)}
|
||||
let pending = Buffer.alloc(0)
|
||||
let receivedLineCount = 0
|
||||
|
||||
process.stdout.write(readyMarker + '\\n')
|
||||
process.stdin.on('data', (chunk) => {
|
||||
pending = Buffer.concat([pending, Buffer.from(chunk)])
|
||||
let newlineIndex = pending.indexOf(0x0a)
|
||||
while (newlineIndex >= 0) {
|
||||
const line = pending.subarray(0, newlineIndex + 1)
|
||||
pending = pending.subarray(newlineIndex + 1)
|
||||
receivedLineCount += 1
|
||||
process.stdout.write(resultPrefix + ':' + receivedLineCount + ':' + line.toString('hex') + '\\n')
|
||||
if (receivedLineCount === expectedLineCount) {
|
||||
process.exit(0)
|
||||
}
|
||||
newlineIndex = pending.indexOf(0x0a)
|
||||
}
|
||||
})
|
||||
`
|
||||
writeFileSync(scriptPath, source)
|
||||
return { expectedLineCount, readyMarker, resultPrefix, scriptPath }
|
||||
}
|
||||
|
||||
export async function startTerminalImeByteReader(
|
||||
page: Page,
|
||||
ptyId: string,
|
||||
reader: TerminalImeByteReader
|
||||
): Promise<void> {
|
||||
await sendToTerminal(page, ptyId, `node ${JSON.stringify(reader.scriptPath)}\r`)
|
||||
await waitForTerminalOutput(page, reader.readyMarker, 10_000, 20_000)
|
||||
}
|
||||
|
||||
export async function waitForTerminalImeBytes(
|
||||
page: Page,
|
||||
reader: TerminalImeByteReader,
|
||||
timeoutMs = 15_000
|
||||
): Promise<string[]> {
|
||||
let results: string[] = []
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const terminal = await getTerminalContent(page, 100_000)
|
||||
const resultPattern = new RegExp(`${reader.resultPrefix}:(\\d+):([0-9a-f]+)`, 'g')
|
||||
const bySequence = new Map<number, string>()
|
||||
for (const match of terminal.matchAll(resultPattern)) {
|
||||
bySequence.set(Number(match[1]), match[2])
|
||||
}
|
||||
results = [...bySequence.entries()]
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([, hex]) => hex)
|
||||
return results.length
|
||||
},
|
||||
{ timeout: timeoutMs, message: 'IME byte reader did not receive every expected line' }
|
||||
)
|
||||
.toBe(reader.expectedLineCount)
|
||||
return results
|
||||
}
|
||||
|
||||
export function removeTerminalImeByteReader(reader: TerminalImeByteReader): void {
|
||||
rmSync(reader.scriptPath, { force: true })
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
import type { CDPSession, Page, TestInfo } from '@stablyai/playwright-test'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
focusActiveTerminalInput,
|
||||
sendToTerminal,
|
||||
waitForActivePanePtyId,
|
||||
waitForActiveTerminalManager
|
||||
} from './helpers/terminal'
|
||||
import {
|
||||
attachTerminalImeBoundaryEvidence,
|
||||
disposeTerminalImeBoundaryProbe,
|
||||
installTerminalImeBoundaryProbe,
|
||||
readTerminalImeBoundaryTrace,
|
||||
type TerminalImeBoundaryTrace
|
||||
} from './terminal-ime-boundary-probe'
|
||||
import {
|
||||
createTerminalImeByteReader,
|
||||
removeTerminalImeByteReader,
|
||||
startTerminalImeByteReader,
|
||||
waitForTerminalImeBytes
|
||||
} from './terminal-ime-byte-reader'
|
||||
import {
|
||||
dispatchObservedIbusHangulMixedSequence,
|
||||
dispatchObservedIbusHangulRetainedCommitSequence
|
||||
} from './terminal-ime-observed-event-sequences'
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
type TraceAssertion = (trace: TerminalImeBoundaryTrace) => void
|
||||
|
||||
async function runExactByteScenario(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
testRepoPath: string,
|
||||
expectedText: string,
|
||||
dispatchInput: (page: Page) => Promise<void>,
|
||||
assertTrace: TraceAssertion
|
||||
): Promise<void> {
|
||||
await waitForSessionReady(page)
|
||||
await waitForActiveWorktree(page)
|
||||
await ensureTerminalVisible(page)
|
||||
await waitForActiveTerminalManager(page, 30_000)
|
||||
|
||||
const ptyId = await waitForActivePanePtyId(page)
|
||||
const reader = createTerminalImeByteReader(testRepoPath, 1)
|
||||
let completed = false
|
||||
let receivedBytes: string[] = []
|
||||
try {
|
||||
await startTerminalImeByteReader(page, ptyId, reader)
|
||||
await focusActiveTerminalInput(page)
|
||||
await installTerminalImeBoundaryProbe(page)
|
||||
await dispatchInput(page)
|
||||
|
||||
receivedBytes = await waitForTerminalImeBytes(page, reader)
|
||||
const expectedBytes = Buffer.from(`${expectedText}\n`).toString('hex')
|
||||
expect(receivedBytes).toEqual([expectedBytes])
|
||||
|
||||
const trace = await readTerminalImeBoundaryTrace(page)
|
||||
expect(trace.onData.join('')).toBe(`${expectedText}\r`)
|
||||
assertTrace(trace)
|
||||
completed = true
|
||||
} finally {
|
||||
await attachTerminalImeBoundaryEvidence(page, testInfo, 'terminal-ime-boundaries', {
|
||||
expectedText,
|
||||
receivedBytes
|
||||
}).catch(() => undefined)
|
||||
await disposeTerminalImeBoundaryProbe(page).catch(() => undefined)
|
||||
if (!completed) {
|
||||
await sendToTerminal(page, ptyId, '\x03').catch(() => undefined)
|
||||
}
|
||||
removeTerminalImeByteReader(reader)
|
||||
}
|
||||
}
|
||||
|
||||
async function dispatchRepeatedConversion(
|
||||
page: Page,
|
||||
frames: string[],
|
||||
committedText: string
|
||||
): Promise<void> {
|
||||
const session: CDPSession = await page.context().newCDPSession(page)
|
||||
try {
|
||||
for (let repetition = 0; repetition < 2; repetition += 1) {
|
||||
for (const frame of frames) {
|
||||
await session.send('Input.imeSetComposition', {
|
||||
text: frame,
|
||||
selectionStart: frame.length,
|
||||
selectionEnd: frame.length
|
||||
})
|
||||
}
|
||||
await session.send('Input.insertText', { text: committedText })
|
||||
}
|
||||
await page.keyboard.press('Enter')
|
||||
} finally {
|
||||
await session.detach()
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Terminal IME exact-byte forwarding', () => {
|
||||
test.skip(process.platform !== 'linux', 'Linux composition order is covered by this suite')
|
||||
|
||||
test('replays the observed IBus Hangul mixed-input order through xterm and the PTY', async ({
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}, testInfo) => {
|
||||
await runExactByteScenario(
|
||||
orcaPage,
|
||||
testInfo,
|
||||
testRepoPath,
|
||||
'한abc글',
|
||||
dispatchObservedIbusHangulMixedSequence,
|
||||
(trace) => {
|
||||
const commits = trace.dom
|
||||
.filter((event) => event.type === 'input' && event.inputType === 'insertText')
|
||||
.map((event) => event.data)
|
||||
expect(commits).toEqual(expect.arrayContaining(['한', '글']))
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test('keeps retained Hangul commits and stale fallbacks in their transactions', async ({
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}, testInfo) => {
|
||||
await runExactByteScenario(
|
||||
orcaPage,
|
||||
testInfo,
|
||||
testRepoPath,
|
||||
'테a스',
|
||||
dispatchObservedIbusHangulRetainedCommitSequence,
|
||||
(trace) => {
|
||||
const starts = trace.dom.filter((event) => event.type === 'compositionstart')
|
||||
expect(starts).toHaveLength(2)
|
||||
expect(trace.onData.join('')).not.toContain('\x7f')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
for (const scenario of [
|
||||
{ name: 'Japanese', frames: ['に', 'にほんご', '日本語'], committedText: '日本語' },
|
||||
{ name: 'Chinese', frames: ['n', 'ni', '你好'], committedText: '你好' }
|
||||
]) {
|
||||
test(`does not suppress repeated legitimate ${scenario.name} conversions`, async ({
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}, testInfo) => {
|
||||
await runExactByteScenario(
|
||||
orcaPage,
|
||||
testInfo,
|
||||
testRepoPath,
|
||||
scenario.committedText.repeat(2),
|
||||
(page) => dispatchRepeatedConversion(page, scenario.frames, scenario.committedText),
|
||||
(trace) => {
|
||||
const commits = trace.dom.filter(
|
||||
(event) => event.type === 'compositionend' && event.data === scenario.committedText
|
||||
)
|
||||
expect(commits).toHaveLength(2)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import type { Page } from '@stablyai/playwright-test'
|
||||
|
||||
async function dispatchObservedIbusHangulSequence(
|
||||
page: Page,
|
||||
variant: 'mixed' | 'retained'
|
||||
): Promise<void> {
|
||||
await page.evaluate((selectedVariant) => {
|
||||
const textarea = document.activeElement
|
||||
if (!(textarea instanceof HTMLTextAreaElement)) {
|
||||
throw new Error('xterm helper textarea is not focused')
|
||||
}
|
||||
const composition = (type: string, data = ''): void => {
|
||||
textarea.dispatchEvent(new CompositionEvent(type, { bubbles: true, data }))
|
||||
}
|
||||
const input = (type: 'beforeinput' | 'input', inputType: string, data?: string): void => {
|
||||
textarea.dispatchEvent(
|
||||
new InputEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: type === 'beforeinput',
|
||||
composed: true,
|
||||
data: data ?? null,
|
||||
inputType
|
||||
})
|
||||
)
|
||||
}
|
||||
const replaceAndInput = (value: string, inputType: string, data?: string): void => {
|
||||
input('beforeinput', inputType, data)
|
||||
textarea.value = value
|
||||
input('input', inputType, data)
|
||||
}
|
||||
const keydown = (key: string, code: string, keyCode: number, isComposing = false): void => {
|
||||
const event = new KeyboardEvent('keydown', { bubbles: true, code, isComposing, key })
|
||||
Object.defineProperty(event, 'keyCode', { value: keyCode })
|
||||
textarea.dispatchEvent(event)
|
||||
}
|
||||
const update = (prefix: string, text: string): void => {
|
||||
composition('compositionupdate', text)
|
||||
replaceAndInput(`${prefix}${text}`, 'insertCompositionText', text)
|
||||
}
|
||||
const begin = (text: string): string => {
|
||||
const prefix = textarea.value
|
||||
textarea.setSelectionRange(prefix.length, prefix.length)
|
||||
composition('compositionstart')
|
||||
keydown('Process', 'KeyG', 229, true)
|
||||
update(prefix, text)
|
||||
return prefix
|
||||
}
|
||||
const end = (prefix: string): void => {
|
||||
composition('compositionupdate')
|
||||
replaceAndInput(prefix, 'deleteContentBackward')
|
||||
composition('compositionend')
|
||||
}
|
||||
const commit = (prefix: string, text: string): void => {
|
||||
end(prefix)
|
||||
replaceAndInput(`${prefix}${text}`, 'insertText', text)
|
||||
}
|
||||
|
||||
if (selectedVariant === 'mixed') {
|
||||
let prefix = begin('한')
|
||||
commit(prefix, '한')
|
||||
for (const character of 'abc') {
|
||||
keydown(character, `Key${character.toUpperCase()}`, character.charCodeAt(0))
|
||||
replaceAndInput(`${textarea.value}${character}`, 'insertText', character)
|
||||
}
|
||||
prefix = begin('글')
|
||||
commit(prefix, '글')
|
||||
keydown('Enter', 'Enter', 13)
|
||||
return
|
||||
}
|
||||
|
||||
const prefix = begin('테')
|
||||
composition('compositionend', '테')
|
||||
keydown('a', 'KeyA', 65)
|
||||
keydown('Process', 'KeyR', 229, true)
|
||||
textarea.setSelectionRange(prefix.length + 1, prefix.length + 1)
|
||||
composition('compositionstart')
|
||||
update(`${prefix}테`, '스')
|
||||
composition('compositionend', '스')
|
||||
keydown('Enter', 'Enter', 13)
|
||||
}, variant)
|
||||
}
|
||||
|
||||
export async function dispatchObservedIbusHangulMixedSequence(page: Page): Promise<void> {
|
||||
await dispatchObservedIbusHangulSequence(page, 'mixed')
|
||||
}
|
||||
|
||||
export async function dispatchObservedIbusHangulRetainedCommitSequence(page: Page): Promise<void> {
|
||||
await dispatchObservedIbusHangulSequence(page, 'retained')
|
||||
}
|
||||
Loading…
Reference in New Issue