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(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 } function nativeMenu() { let template let close let clipboard = '' let copies = 0 let pastes = 0 const frame = {} const sender = { mainFrame: frame, isDestroyed: () => false, copy() { copies++ }, paste() { pastes++ } } const event = { sender, senderFrame: frame } const { showChatTextMenu } = compile('../src/main/chatTextMenu.ts', { electron: { BrowserWindow: { fromWebContents: () => ({ isDestroyed: () => false }) }, clipboard: { readText: () => clipboard }, Menu: { buildFromTemplate(items) { template = items return { popup(options) { close = options.callback } } } } } }) return { show: options => showChatTextMenu(event, options), items: () => template, close: () => close(), setClipboard: text => { clipboard = text }, copies: () => copies, pastes: () => pastes, event } } const labels = { copy: '복사', paste: '붙여넣기', clear: '대화내역 삭제' } for (const canCopy of [false, true]) { test(`message copy is enabled only with a selection (selected=${canCopy})`, async () => { const menu = nativeMenu() const result = menu.show({ kind: 'message', canCopy, canClear: true, labels }) assert.equal(menu.items()[0].enabled, canCopy) assert.equal(menu.items()[0].label, '복사') assert.equal(menu.items().some(item => item.label === '붙여넣기'), false) menu.items()[0].click() assert.equal(await result, null) assert.equal(menu.copies(), canCopy ? 1 : 0) }) } for (const clipboard of ['', '복사해 둔 내용']) { test(`input paste availability is read from the OS clipboard (empty=${!clipboard})`, async () => { const menu = nativeMenu() menu.setClipboard(clipboard) const result = menu.show({ kind: 'input', canCopy: false, canPaste: true, labels }) assert.equal(menu.items().length, 2) assert.equal(menu.items()[0].enabled, false) assert.equal(menu.items()[1].label, '붙여넣기') assert.equal(menu.items()[1].enabled, Boolean(clipboard)) menu.items()[1].click() await result assert.equal(menu.pastes(), clipboard ? 1 : 0) }) } test('read-only input never allows paste, even with clipboard text', async () => { const menu = nativeMenu() menu.setClipboard('text') const result = menu.show({ kind: 'input', canCopy: true, canPaste: false, labels }) assert.equal(menu.items()[1].enabled, false) menu.items()[1].click() await result assert.equal(menu.pastes(), 0) }) test('paste rechecks an emptied clipboard before editing the input', async () => { const menu = nativeMenu() menu.setClipboard('text') const result = menu.show({ kind: 'input', canCopy: false, canPaste: true, labels }) assert.equal(menu.items()[1].enabled, true) menu.setClipboard('') menu.items()[1].click() await result assert.equal(menu.pastes(), 0) }) test('delete selection returns a confirmation request rather than deleting data', async () => { const menu = nativeMenu() const result = menu.show({ kind: 'message', canCopy: false, canClear: true, labels }) menu.items().find(item => item.label === '대화내역 삭제').click() assert.equal(await result, 'clear') }) test('closing a menu without choosing an action does nothing', async () => { const menu = nativeMenu() const result = menu.show({ kind: 'message', canCopy: true, labels }) menu.close() assert.equal(await result, null) assert.equal(menu.copies(), 0) }) test('untrusted frames and invalid menu targets cannot open a native menu', () => { const menu = nativeMenu() assert.equal(menu.show({ kind: 'unknown', canCopy: true, labels }), null) menu.event.senderFrame = {} assert.equal(menu.show({ kind: 'input', canCopy: true, labels }), null) }) function elements(root, predicate) { if (!React.isValidElement(root)) return [] return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))] } for (const selected of [false, true]) { 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': {}, './ChatTextContextMenu': { ChatTextContextMenu: () => null }, './ChatImageAttachments': { ChatImageAttachments: () => null }, './ChatCodeBlock': {}, 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: { 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.text, selected ? 'cde' : '') assert.equal(options.input.disabled, false) assert.equal(options.selectionStart, 2) assert.equal(prevented, true) assert.equal(stopped, true) }) }