feat: improve chat input, themed menus and agent proposal cancellation

This commit is contained in:
2026-10-19 13:12:04 +09:00
parent 105342fbf3
commit 7b32668e1d
18 changed files with 641 additions and 94 deletions
+145
View File
@@ -0,0 +1,145 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const React = require('react')
function compile(source, globals) {
const exports = {}
vm.runInNewContext(ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
}).outputText, { exports, require, ...globals })
return exports
}
const hookSource = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
const hook = hookSource.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'useAiAgent')
const functions = hook.body.statements.filter(node => ts.isVariableStatement(node) && ['applyPlan', 'cancelPlan'].includes(node.declarationList.declarations[0].name.getText(hookSource)))
const proposal = (extra = {}) => ({
id: 'proposal', role: 'assistant', content: '로그 추가', applyState: 'ready',
plan: { summary: '로그 추가', explanation: '시작 로그', requestedModules: [], requestedLogs: [], actionPlan: [], createModule: null,
changes: [{ moduleName: 'Control', path: 'node.py', content: 'new', reason: 'log' }],
commands: [{ id: 'build', moduleName: 'Control', command: 'bash build.sh', purpose: 'build' }] },
commandExecutions: { build: { status: 'pending' } }, ...extra
})
function harness(message) {
const session = {}
const messageStateRef = { current: { session, messages: [message] } }
const applyingRef = { current: false }
const abortControllerRef = { current: null }
const saved = []
const api = compile(functions.map(node => `export ${node.getText(hookSource)}`).join('\n'), {
session, messageStateRef, applyingRef, abortControllerRef,
updateMessages(update) {
const next = update(messageStateRef.current.messages)
messageStateRef.current.messages = next
saved.push(JSON.parse(JSON.stringify(next)))
}
})
return { ...api, messageStateRef, applyingRef, abortControllerRef, saved, current: () => messageStateRef.current.messages[0] }
}
test('cancel marks the proposal and unfinished commands without applying files', async () => {
const h = harness(proposal({ applyError: 'old error' }))
assert.equal(h.cancelPlan('proposal'), true)
assert.equal(h.current().applyState, 'cancelled')
assert.equal(h.current().applyError, undefined)
assert.equal(h.current().commandExecutions.build.status, 'rejected')
assert.equal(h.saved.at(-1)[0].applyState, 'cancelled')
let applied = false
await h.applyPlan('proposal', async () => { applied = true })
assert.equal(applied, false)
assert.equal(h.cancelPlan('proposal'), false)
})
test('cancel preserves existing successful command results and rejects failed commands', () => {
const message = proposal()
message.plan.commands.push({ id: 'inspect', moduleName: 'Control', command: 'inspect', purpose: 'inspect' })
message.commandExecutions = { build: { status: 'failed', output: 'build failed' }, inspect: { status: 'completed', output: 'done' } }
const h = harness(message)
h.cancelPlan('proposal')
assert.equal(h.current().commandExecutions.inspect.status, 'completed')
assert.equal(h.current().commandExecutions.inspect.output, 'done')
assert.equal(h.current().commandExecutions.build.status, 'rejected')
assert.equal(h.current().commandExecutions.build.output, 'build failed')
})
for (const applyState of ['applying', 'applied', 'cancelled']) {
test(`cannot cancel a proposal in ${applyState} state`, () => {
const h = harness(proposal({ applyState }))
assert.equal(h.cancelPlan('proposal'), false)
assert.equal(h.saved.length, 0)
})
}
test('cannot cancel during a running command, another apply, pending request, or changed conversation', () => {
for (const setup of [
h => { h.current().commandExecutions.build.status = 'running' },
h => { h.applyingRef.current = true },
h => { h.abortControllerRef.current = {} },
h => { h.messageStateRef.current.session = {} }
]) {
const h = harness(proposal())
setup(h)
assert.equal(h.cancelPlan('proposal'), false)
assert.equal(h.saved.length, 0)
}
})
test('an apply already started cannot be cancelled and completes normally', async () => {
const h = harness(proposal())
let finish
const applying = h.applyPlan('proposal', () => new Promise(resolve => { finish = resolve }))
assert.equal(h.current().applyState, 'applying')
assert.equal(h.cancelPlan('proposal'), false)
finish()
await applying
assert.equal(h.current().applyState, 'applied')
})
test('new module proposals can be cancelled without creating anything', () => {
const message = proposal()
message.plan.changes = []
message.plan.createModule = { name: 'Example' }
const h = harness(message)
assert.equal(h.cancelPlan('proposal'), true)
assert.equal(h.current().applyState, 'cancelled')
})
function elements(root, predicate) {
if (!React.isValidElement(root)) return []
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
}
function text(root) {
if (typeof root === 'string') return root
return React.isValidElement(root) ? React.Children.toArray(root.props.children).map(text).join('') : ''
}
const panelSource = ts.createSourceFile('WorkspaceQaPanel.tsx', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
const renderAgent = compile(`export ${panelSource.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'AgentMessages').getText(panelSource)}`, {
...require('lucide-react'), t: key => key, useLocaleRender() {},
ProgressiveChatResponse: () => null, MessageCopyButton: () => null,
agentMessageCopyText: message => message.content
}).AgentMessages
for (const dark of [true, false]) {
test(`cancel is left of apply, disabled during work, and absent after cancellation (${dark ? 'dark' : 'light'})`, () => {
let cancelled
const props = { dark, enabled: true, statusLoading: false, pending: false,
messages: [proposal()], onApply() {}, onCancel: id => { cancelled = id }, onOpenChange() {}, onRunCommand() {}, onRejectCommand() {} }
let tree = renderAgent(props)
let buttons = elements(tree, node => node.type === 'button')
const cancel = buttons.find(button => text(button) === '취소')
const apply = buttons.find(button => text(button) === '변경 적용')
assert.ok(buttons.indexOf(cancel) < buttons.indexOf(apply))
const cancelLabel = elements(cancel, node => node.type === 'span' && text(node) === '취소')[0]
assert.match(cancelLabel.props.className, /top-px leading-none/)
cancel.props.onClick()
assert.equal(cancelled, 'proposal')
const busyCancel = elements(renderAgent({ ...props, busy: true }), node => node.type === 'button' && text(node) === '취소')[0]
assert.equal(busyCancel.props.disabled, true)
tree = renderAgent({ ...props, messages: [proposal({ applyState: 'cancelled' })] })
buttons = elements(tree, node => node.type === 'button')
assert.equal(buttons.some(button => ['취소', '승인', '다시 적용', '변경 적용'].includes(text(button))), false)
assert.equal(buttons.find(button => text(button) === '취소됨').props.disabled, true)
})
}
+3 -3
View File
@@ -72,7 +72,7 @@ test('long answers are grouped into at most twenty steps and finish within five
test('only the newly appended paragraph gets the fade class', () => {
const { MarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}
})
const document = createAgentRevealDocument('첫 번째 문단입니다.\n\n두 번째 문단입니다.\n\n세 번째 문단입니다.')
const frame = agentRevealFrame(document, 0, AGENT_REVEAL_INTERVAL)
@@ -159,7 +159,7 @@ for (const dark of [false, true]) {
test(`shared answer can expand and collapse while preserving full source text (${dark ? 'dark' : 'light'})`, () => {
let expanded
const { ExpandableMarkdownMessage, MarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {},
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {},
react: { ...React, useEffect() {}, useState(initial) {
if (expanded === undefined) expanded = initial
return [expanded, update => { expanded = typeof update === 'function' ? update(expanded) : update }]
@@ -183,7 +183,7 @@ for (const dark of [false, true]) {
test('short answers do not show an unnecessary collapse button', () => {
const { ExpandableMarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {},
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {},
react: { ...React, useEffect() {}, useState: initial => [initial, () => {}] }
})
const tree = ExpandableMarkdownMessage({ content: '짧은 답변입니다.', dark: true, defaultCollapsed: true })
+1 -1
View File
@@ -16,7 +16,7 @@ function compile(source, overrides = {}, globals = {}) {
}
const widgets = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8'), {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}
})
const panelSource = ts.createSourceFile('WorkspaceQaPanel.tsx', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
const declarations = panelSource.statements.filter(node => ts.isFunctionDeclaration(node) && ['AiMessages', 'AgentMessages', 'TeacherMessages'].includes(node.name?.text))
+14 -10
View File
@@ -19,6 +19,7 @@ function compile(file, overrides = {}, globals = {}) {
const i18n = { t: key => key, useLocaleRender() {} }
const { ConfirmStatusModal } = compile('../src/renderer/src/components/AppStatusModals.tsx', { '../i18n': i18n })
const QaInputBox = () => null
const ChatTextContextMenu = () => null
function elements(root, predicate) {
if (!React.isValidElement(root)) return []
@@ -36,6 +37,7 @@ function setup(overrides = {}, browser = {}) {
'../../components/AppStatusModals': { ConfirmStatusModal },
'../../components/ProgressiveChatResponse': { ProgressiveChatResponse: () => null },
'../../components/ChatWidgets': { QaInputBox },
'../../components/ChatTextContextMenu': { ChatTextContextMenu },
'react-dom': { createPortal: child => child },
react: {
...React,
@@ -84,19 +86,18 @@ test('context menu deletion also requests confirmation', () => {
const root = harness.render()
const area = elements(root, node => Boolean(node.props.onContextMenu))[0]
area.props.onContextMenu({ currentTarget: { contains: () => true }, preventDefault() {}, stopPropagation() {}, clientX: 10, clientY: 10 })
const clear = elements(harness.render(), node => node.type === 'button' && node.props.children === '대화내역 삭제')[0]
clear.props.onClick()
const menu = elements(harness.render(), node => node.type === ChatTextContextMenu)[0]
menu.props.onClear()
assert.equal(harness.cleared(), 0)
assert.equal(elements(harness.render(), node => node.type === ConfirmStatusModal).length, 1)
})
for (const [selected, inside] of [[false, true], [true, true], [true, false]]) {
test(`native chat menu copies only a selection inside the chat (selected=${selected}, inside=${inside})`, async () => {
let request
test(`themed chat menu copies only a selection inside the chat (selected=${selected}, inside=${inside})`, async () => {
const node = {}
const harness = setup({}, {
getSelection: () => selected ? { anchorNode: node, focusNode: node, toString: () => '선택한 답변' } : null,
chatTextMenu: { show: async options => { request = options; return null } }
chatTextMenu: { show: async () => assert.fail('the themed menu must not open an OS menu') }
})
const area = elements(harness.render(), element => Boolean(element.props.onContextMenu))[0]
area.props.onContextMenu({
@@ -104,18 +105,21 @@ for (const [selected, inside] of [[false, true], [true, true], [true, false]]) {
preventDefault() {}, stopPropagation() {}, clientX: 10, clientY: 10
})
await Promise.resolve()
assert.equal(request.kind, 'message')
assert.equal(request.canCopy, selected && inside)
assert.equal(request.canClear, true)
const menu = elements(harness.render(), node => node.type === ChatTextContextMenu)[0]
assert.equal(menu.props.menu.kind, 'message')
assert.equal(Boolean(menu.props.menu.text), selected && inside)
assert.equal(menu.props.canClear, true)
assert.equal(harness.cleared(), 0)
})
}
test('native menu deletion still goes through the shared confirmation dialog', async () => {
const harness = setup({}, { chatTextMenu: { show: async () => 'clear' } })
test('themed menu deletion still goes through the shared confirmation dialog', async () => {
const harness = setup()
const area = elements(harness.render(), node => Boolean(node.props.onContextMenu))[0]
area.props.onContextMenu({ currentTarget: { contains: () => true }, preventDefault() {}, stopPropagation() {} })
await Promise.resolve()
const menu = elements(harness.render(), node => node.type === ChatTextContextMenu)[0]
menu.props.onClear()
assert.equal(harness.cleared(), 0)
const modal = elements(harness.render(), node => node.type === ConfirmStatusModal)[0]
assert.ok(modal)
+81
View File
@@ -0,0 +1,81 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const React = require('react')
const { renderToStaticMarkup } = require('react-dom/server')
function compile(file, overrides = {}, globals = {}) {
const exports = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve(file), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
}).outputText, { exports, require: name => overrides[name] ?? require(name), ...globals })
return exports
}
test('custom menu clipboard IPC validates the main frame and rechecks paste availability', () => {
let clipboard = ''
let pastes = 0
const frame = {}
const sender = { mainFrame: frame, isDestroyed: () => false, paste() { pastes++ } }
const event = { sender, senderFrame: frame }
const native = compile('../src/main/chatTextMenu.ts', {
electron: { BrowserWindow: { fromWebContents: () => ({ isDestroyed: () => false }) }, clipboard: { readText: () => clipboard, writeText: text => { clipboard = text } } }
})
assert.equal(native.readChatClipboardState(event).hasText, false)
assert.equal(native.performChatClipboardAction(event, { action: 'paste' }), false)
assert.equal(native.performChatClipboardAction(event, { action: 'copy', text: 'selected text' }), true)
assert.equal(clipboard, 'selected text')
assert.equal(native.readChatClipboardState(event).hasText, true)
assert.equal(native.performChatClipboardAction(event, { action: 'paste' }), true)
assert.equal(pastes, 1)
clipboard = ''
assert.equal(native.performChatClipboardAction(event, { action: 'paste' }), false)
assert.equal(pastes, 1)
event.senderFrame = {}
assert.equal(native.performChatClipboardAction(event, { action: 'copy', text: 'blocked' }), false)
assert.equal(native.readChatClipboardState(event).hasText, false)
event.senderFrame = frame
assert.equal(native.performChatClipboardAction(event, { action: 'copy', text: '' }), false)
assert.equal(native.performChatClipboardAction(event, { action: 'copy', text: 'x'.repeat(5_000_001) }), false)
assert.equal(native.performChatClipboardAction(event, { action: 'unknown' }), false)
})
function renderMenu({ dark, kind = 'message', text = '', hasText = false, readOnly = false }) {
const menu = { kind, x: 20, y: 30, text, input: { readOnly, disabled: false } }
let state = 0
const { ChatTextContextMenu } = compile('../src/renderer/src/components/ChatTextContextMenu.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} },
'react-dom': { createPortal: child => child },
react: { ...React, useEffect() {}, useLayoutEffect() {}, useRef: initial => ({ current: initial }), useState() { return [state++ === 0 ? { menu, hasText } : { x: 20, y: 30 }, () => {}] } }
}, { document: { body: {} } })
return ChatTextContextMenu({ dark, menu, onClose() {}, onClear() {}, canClear: true })
}
for (const dark of [true, false]) {
test(`chat menu uses the terminal menu surface and item metrics (${dark ? 'dark' : 'light'})`, () => {
const tree = renderMenu({ dark, text: 'selected' })
const html = renderToStaticMarkup(tree)
assert.match(html, dark ? /bg-\[#252526\]/ : /bg-white/)
assert.match(html, dark ? /enabled:hover:bg-\[#37373d\]/ : /enabled:hover:bg-\[#eef1f7\]/)
assert.match(html, /rounded-md border py-1 text-\[12px\] font-medium/)
assert.match(html, /flex h-8 w-full cursor-pointer/)
assert.match(html, /role="menu"/)
assert.match(html, /role="menuitem"/)
assert.match(html, /대화내역 삭제/)
assert.doesNotMatch(html, /붙여넣기/)
})
}
for (const hasText of [true, false]) {
for (const readOnly of [true, false]) {
test(`paste disabled state follows clipboard and input editability (clipboard=${hasText}, readOnly=${readOnly})`, () => {
const tree = renderMenu({ dark: true, kind: 'input', hasText, readOnly })
const buttons = React.Children.toArray(tree.props.children).filter(child => child.type === 'button')
assert.equal(buttons[0].props.disabled, true)
assert.equal(buttons[1].props.disabled, !hasText || readOnly)
assert.doesNotMatch(renderToStaticMarkup(tree), /대화내역 삭제/)
})
}
}
+135
View File
@@ -0,0 +1,135 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const React = require('react')
function harness() {
let layoutEffects = []
let effects = []
let resizeCallback
let disconnected = false
const states = []
const refs = []
let stateIndex = 0
let refIndex = 0
const submissions = []
const input = {
style: {}, scrollHeight: 56,
getBoundingClientRect: () => ({ width: 300 })
}
const exports = {}
const source = fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8')
vm.runInNewContext(ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
}).outputText, {
exports,
window: { getComputedStyle: () => ({ lineHeight: '20px', paddingTop: '10px', paddingBottom: '10px', borderTopWidth: '0px', borderBottomWidth: '0px', minHeight: '56px' }) },
ResizeObserver: class {
constructor(callback) { resizeCallback = callback }
observe(element) { assert.equal(element, input) }
disconnect() { disconnected = true }
},
require(name) {
if (name === '../i18n') return { t: key => key, useLocaleRender() {} }
if (name === './chatAttachments') return {}
if (name === './ChatTextContextMenu') return { ChatTextContextMenu: () => null }
if (name === 'react') return {
...React,
useState(initial) {
const index = stateIndex++
if (!(index in states)) states[index] = initial
return [states[index], value => { states[index] = value }]
},
useRef(initial) { return refs[refIndex++] ??= { current: initial } },
useEffect(callback) { effects.push(callback) },
useLayoutEffect(callback) { layoutEffects.push(callback) }
}
return require(name)
}
})
function find(node, type) {
if (!React.isValidElement(node)) return null
if (node.type === type) return node
return React.Children.toArray(node.props.children).map(child => find(child, type)).find(Boolean)
}
function render() {
stateIndex = 0
refIndex = 0
layoutEffects = []
effects = []
const tree = exports.QaInputBox.type({ dark: true, busy: false, disabled: false, onStop() {}, onSubmit: content => submissions.push(content) })
const textarea = find(tree, 'textarea')
textarea.props.ref.current = input
layoutEffects.forEach(callback => callback())
return textarea
}
return {
input, render, submissions,
mountEffects: () => effects.map(callback => callback()),
resize: width => resizeCallback([{ contentRect: { width } }]),
disconnected: () => disconnected
}
}
test('input grows to eight visible lines and scrolls only above the cap', () => {
const h = harness()
h.render()
assert.equal(h.input.style.height, '56px')
assert.equal(h.input.style.overflowY, 'hidden')
h.input.scrollHeight = 100
h.render()
assert.equal(h.input.style.height, '100px')
h.input.scrollHeight = 180
h.render()
assert.equal(h.input.style.height, '180px')
assert.equal(h.input.style.overflowY, 'hidden')
h.input.scrollHeight = 300
h.render()
assert.equal(h.input.style.height, '180px')
assert.equal(h.input.style.overflowY, 'auto')
})
test('deleting text and submitting restore the initial height', () => {
const h = harness()
let textarea = h.render()
textarea.props.onChange({ target: { value: 'question\nsecond line\nthird line' } })
h.input.scrollHeight = 80
textarea = h.render()
assert.equal(h.input.style.height, '80px')
let prevented = false
textarea.props.onKeyDown({ key: 'Enter', shiftKey: false, nativeEvent: {}, preventDefault() { prevented = true } })
assert.equal(prevented, true)
assert.equal(h.submissions.length, 1)
h.input.scrollHeight = 40
textarea = h.render()
assert.equal(textarea.props.value, '')
assert.equal(h.input.style.height, '56px')
assert.equal(h.input.style.overflowY, 'hidden')
})
test('Shift+Enter and IME composition do not submit', () => {
const h = harness()
let textarea = h.render()
textarea.props.onChange({ target: { value: 'question' } })
textarea = h.render()
const preventDefault = () => assert.fail('newline or composing Enter must not be intercepted')
textarea.props.onKeyDown({ key: 'Enter', shiftKey: true, nativeEvent: {}, preventDefault })
textarea.props.onKeyDown({ key: 'Enter', shiftKey: false, nativeEvent: { isComposing: true }, preventDefault })
assert.equal(h.submissions.length, 0)
})
test('panel width changes recalculate wrapped lines and observer disconnects on unmount', () => {
const h = harness()
h.render()
const cleanups = h.mountEffects()
h.input.scrollHeight = 120
h.resize(180)
assert.equal(h.input.style.height, '120px')
h.input.scrollHeight = 50
h.resize(400)
assert.equal(h.input.style.height, '56px')
cleanups.forEach(cleanup => cleanup?.())
assert.equal(h.disconnected(), true)
})
+7 -7
View File
@@ -120,25 +120,25 @@ function elements(root, predicate) {
}
for (const selected of [false, true]) {
test(`textarea opens the menu with native selection and editability (selected=${selected})`, async () => {
test(`textarea opens the themed menu with a selection snapshot (selected=${selected})`, async () => {
let options
let prevented = false
let stopped = false
const { QaInputBox } = compile('../src/renderer/src/components/ChatWidgets.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {},
react: { ...React, useState: initial => [initial, () => {}], useEffect() {}, useRef: () => ({ current: null }) }
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': { ChatTextContextMenu: () => null },
react: { ...React, useState: initial => [initial, value => { if (value?.kind === 'input') options = value }], useEffect() {}, useLayoutEffect() {}, useRef: () => ({ current: null }) }
}, { window: { chatTextMenu: { show: async input => { options = input; return null } } } })
const tree = QaInputBox.type({ dark: true, busy: false, disabled: false, onStop() {}, onSubmit() {} })
const textarea = elements(tree, node => node.type === 'textarea')[0]
textarea.props.onContextMenu({
currentTarget: { selectionStart: 2, selectionEnd: selected ? 5 : 2, disabled: false, readOnly: false },
currentTarget: { value: 'abcdef', selectionStart: 2, selectionEnd: selected ? 5 : 2, disabled: false, readOnly: false },
preventDefault() { prevented = true }, stopPropagation() { stopped = true }
})
await Promise.resolve()
assert.equal(options.kind, 'input')
assert.equal(options.canCopy, selected)
assert.equal(options.canPaste, true)
assert.equal(options.labels.paste, '붙여넣기')
assert.equal(options.text, selected ? 'cde' : '')
assert.equal(options.input.disabled, false)
assert.equal(options.selectionStart, 2)
assert.equal(prevented, true)
assert.equal(stopped, true)
})