// TODO 12 验收:设置 → 快捷键页的 1:1 结构/几何 + 搜索/录制/取消 + 深浅主题 + 真实绑定标注。 // 真实隔离 YCode(真实 preload/IPC/渲染进程/文件系统)+ 原生设置窗口,视口 900×680(与原版一致)。 // // 原版依据(REA ev_5f3148…/ev_f4fd41…/ev_b3e014…/ev_528724…): // `.keyboard-shortcuts-panel` [232,40,638,620]、toolbar 638×32、search 516×32、恢复默认 110×32、 // list [232,88,638,572]、header 39、scroll [232,127,638,533];24 行(1–16 高 41、17–23 高 45、末行 44), // 键帽高 22;前 16 行可编辑并有删除按钮,后 8 行 not-editable 且操作列为「—」。 const fs = require('node:fs/promises') const path = require('node:path') const assert = require('node:assert/strict') const EXPECTED_LABELS = ['打开全局搜索', '打开设置', '语音录制开关', '对话内搜索', '全局搜索', '发送消息', '输入时换行', '新建对话', '停止生成', '上一个任务', '下一个任务', '定位当前任务', '切换左侧栏', '切换右侧栏', '进入/退出全屏', '唤起/隐藏主窗口', '放大内容字号', '缩小内容字号', '重置内容字号', '唤起选择器', '唤起斜杠命令', '按住后点击,始终外部打开', '新建标签页', '关闭标签页'] const EXPECTED_KEYS = ['Ctrl+K', 'Ctrl+,', 'Alt', 'Ctrl+F', 'Ctrl+K', 'Enter', 'Shift+Enter', 'Ctrl+N', 'Esc', 'Ctrl+[', 'Ctrl+]', 'Ctrl+Shift+E', 'Ctrl+B', 'Ctrl+Shift+B', 'F11', 'Shift+Alt+W', 'Ctrl+"+"', 'Ctrl+"-"', 'Ctrl+0', '@', '/', 'Ctrl', 'Ctrl+T', 'Ctrl+W'] const SUPPORTED_IDS = ['global-search-open', 'open-settings', 'global-search', 'send-message', 'newline', 'new-conversation', 'stop-generation', 'toggle-left-sidebar', 'toggle-right-sidebar', 'font-increase', 'font-decrease', 'font-reset', 'new-tab', 'close-tab'] if (!process.versions.electron) { ;(async () => { const directory = await fs.mkdtemp(path.resolve('.tmp/shortcuts-settings-')) const data = path.join(directory, 'data'), home = path.join(directory, 'home') await fs.mkdir(data, { recursive: true }) await fs.mkdir(home, { recursive: true }) const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_DISABLE_GPU: '1', YCODE_DISABLE_SYSTEM_INTEGRATION: '1' } delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL const child = require('node:child_process').spawn(require('electron'), ['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) let output = '' child.stdout.on('data', bytes => { output += bytes; if (bytes.toString().includes('SHORTCUTS_SETTINGS_')) process.stdout.write(bytes) }) child.stderr.on('data', bytes => { output += bytes }) const timer = setTimeout(() => child.kill(), 300000) child.once('exit', async code => { clearTimeout(timer) await fs.writeFile(path.join(directory, 'process.log'), output) console.log(JSON.stringify({ directory, exitCode: code })) if (code) console.error(output.slice(-4000)) process.exitCode = code ? 1 : 0 }) })().catch(error => { console.error(error); process.exitCode = 1 }) } else { const { app, BrowserWindow } = require('electron') const { pathToFileURL } = require('node:url') const directory = process.env.YCODE_QA_DIRECTORY app.setPath('home', path.join(directory, 'home')) const checks = [], snapshots = {}, rendererErrors = [] let main, settings, phase = 'boot' const pause = ms => new Promise(resolve => setTimeout(resolve, ms)) async function read(source, win = main) { let timer try { return await Promise.race([win.webContents.executeJavaScript(source), new Promise((_, reject) => { timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 120))), 15000) })]) } finally { clearTimeout(timer) } } async function wait(source, label, win = main) { for (let index = 0; index < 300; index++) { const value = await read(source, win); if (value) return value; await pause(40) } throw Error('Timeout: ' + label) } async function step(label) { phase = label; await fs.writeFile(path.join(directory, 'phase.txt'), label) } function check(label, actual, expected) { const pass = typeof expected === 'function' ? expected(actual) : JSON.stringify(actual) === JSON.stringify(expected) checks.push({ label, pass, actual, expected: typeof expected === 'function' ? expected.toString() : expected }) } const near = (value, target, tolerance = 0.75) => Math.abs(value - target) <= tolerance function observe(win) { win.webContents.on('console-message', event => { if (event.level === 'error' || event.level >= 3) rendererErrors.push({ windowId: win.id, message: event.message }) }) } async function openSettings(section) { await read(`window.ycode.app.openSettings(${JSON.stringify(section)})`) for (let index = 0; index < 200; index++) { settings = BrowserWindow.getAllWindows().find(win => win !== main) if (settings && !settings.webContents.isLoading()) break await pause(25) } assert.ok(settings, 'settings window') await wait('!!document.querySelector(".settings-window-root")', 'settings root', settings) await pause(350) return settings } async function sendKey(key, code, keyCode, modifiers = 0) { if (!settings.webContents.debugger.isAttached()) settings.webContents.debugger.attach('1.3') await settings.webContents.debugger.sendCommand('Input.dispatchKeyEvent', { type: 'keyDown', key, code, windowsVirtualKeyCode: keyCode, nativeVirtualKeyCode: keyCode, modifiers }) await settings.webContents.debugger.sendCommand('Input.dispatchKeyEvent', { type: 'keyUp', key, code, windowsVirtualKeyCode: keyCode, nativeVirtualKeyCode: keyCode, modifiers }) await pause(120) } async function sendMainKey(key, code, keyCode, modifiers = 0) { if (!main.webContents.debugger.isAttached()) main.webContents.debugger.attach('1.3') main.showInactive(); main.webContents.focus() await main.webContents.debugger.sendCommand('Input.dispatchKeyEvent', { type: 'keyDown', key, code, windowsVirtualKeyCode: keyCode, nativeVirtualKeyCode: keyCode, modifiers }) await main.webContents.debugger.sendCommand('Input.dispatchKeyEvent', { type: 'keyUp', key, code, windowsVirtualKeyCode: keyCode, nativeVirtualKeyCode: keyCode, modifiers }) await pause(150) } const layoutSource = `(()=>{ const round = value => Math.round(value * 1000) / 1000 const rect = node => { if (!node) return null; const r = node.getBoundingClientRect() return { x: round(r.x), y: round(r.y), width: round(r.width), height: round(r.height) } } const panel = document.querySelector('.keyboard-shortcuts-panel') const rows = [...document.querySelectorAll('.shortcut-item')] return { panel: rect(panel), toolbar: rect(document.querySelector('.keyboard-shortcuts-panel__toolbar')), search: rect(document.querySelector('.keyboard-shortcuts-panel__search')), restore: rect(document.querySelector('.keyboard-shortcuts-panel__toolbar .wb-button')), restoreDisabled: (() => { const node = document.querySelector('.keyboard-shortcuts-panel__toolbar .wb-button'); return node ? node.disabled === true : null })(), list: rect(document.querySelector('.keyboard-shortcuts-panel__list')), header: rect(document.querySelector('.keyboard-shortcuts-panel__column-header')), scroll: rect(document.querySelector('.keyboard-shortcuts-panel__list-scroll')), headerTexts: [...document.querySelectorAll('.keyboard-shortcuts-panel__column-header span')].map(node => node.textContent.trim()), rows: rows.map(row => ({ id: row.dataset.shortcutId, supported: row.dataset.shortcutSupported, label: row.querySelector('.shortcut-item__label')?.textContent?.trim(), keys: [...row.querySelectorAll('.shortcut-item__key')].map(node => node.textContent.trim()), prefix: row.querySelector('.shortcut-item__prefix')?.textContent?.trim(), notEditable: row.classList.contains('shortcut-item--not-editable'), recording: row.classList.contains('shortcut-item--recording'), hasDelete: !!row.querySelector('.shortcut-item__action-btn--delete'), placeholder: row.querySelector('.shortcut-item__actions-placeholder')?.textContent?.trim(), rect: rect(row), keyRect: rect(row.querySelector('.shortcut-item__key')), title: row.querySelector('.shortcut-item__binding-tooltip-anchor')?.getAttribute('title'), rowBackground: getComputedStyle(row).backgroundColor, labelColor: getComputedStyle(row.querySelector('.shortcut-item__label')).color, keyBackground: getComputedStyle(row.querySelector('.shortcut-item__key')).backgroundColor })), recordingText: document.querySelector('.shortcut-item__recording-text')?.textContent?.trim(), escCancel: document.querySelector('.shortcut-item__esc-cancel')?.textContent?.trim(), errorHint: document.querySelector('.shortcut-item__error-hint')?.textContent?.trim(), empty: document.querySelector('.keyboard-shortcuts-panel__empty')?.textContent?.trim(), theme: document.documentElement.dataset.theme ?? 'light' }})()` ;(async () => { await import(pathToFileURL(path.resolve('out/main/index.mjs')).href); await app.whenReady() for (let index = 0; index < 200; index++) { main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode') if (main && !main.webContents.isLoading()) break await pause(25) } assert.ok(main, 'main window'); observe(main) await wait('!!window.ycode', 'preload api') await step('global-shortcuts-main-window') // CDP modifiers 位掩码:Alt=1、Ctrl=2、Meta=4、Shift=8。 const collapsedBefore = await read(`localStorage.getItem('ycode:sidebar-collapsed')`) await sendMainKey('b', 'KeyB', 66, 2) const collapsedAfter = await wait(`localStorage.getItem('ycode:sidebar-collapsed')==='1'?'1':null`, 'sidebar collapsed by Ctrl+B') check('Ctrl+B collapses the left sidebar and persists the state', [collapsedBefore, collapsedAfter], ['0', '1']) await sendMainKey('b', 'KeyB', 66, 2) check('Ctrl+B expands the left sidebar again', await wait(`localStorage.getItem('ycode:sidebar-collapsed')==='0'?'0':null`, 'sidebar expanded by Ctrl+B'), '0') await sendMainKey('k', 'KeyK', 75, 2) const searchState = await wait(`(()=>{const input=document.querySelector('.sidebar__search-input') if(!input)return null const active=document.activeElement return { active: active===input, activeClass: String(active?.className ?? active?.tagName ?? '') }})()`, 'search box visible after Ctrl+K') snapshots.ctrlK = searchState check('Ctrl+K shows and focuses the sidebar search box', searchState.active, true) await sendMainKey('b', 'KeyB', 66, 10) check('Ctrl+Shift+B opens the right panel', await wait(`document.querySelector('.app-shell')?.dataset.right==='shown'?'shown':null`, 'right panel opened'), 'shown') await sendMainKey('b', 'KeyB', 66, 10) check('Ctrl+Shift+B closes the right panel', await wait(`document.querySelector('.app-shell')?.dataset.right==='hidden'?'hidden':null`, 'right panel closed'), 'hidden') await read(`window.__qaNewTask=0;window.addEventListener('ycode:new-task',()=>{window.__qaNewTask++},{once:true});true`) await sendMainKey('n', 'KeyN', 78, 2) check('Ctrl+N dispatches the sidebar 新建任务 path', await wait(`window.__qaNewTask===1?1:null`, 'new task shortcut dispatched'), 1) await step('open-shortcuts-settings') await openSettings('keyboardShortcuts'); observe(settings) await wait('!!document.querySelector(".keyboard-shortcuts-panel")', 'shortcuts panel', settings) const nav = await read(`(()=>{const item=document.querySelector('[data-settings-tab="keyboardShortcuts"]') return { exists: !!item, disabled: item?.getAttribute('aria-disabled'), active: document.querySelector('.settings-navigation__item--active')?.dataset.settingsTab }})()`, settings) check('快捷键 nav entry is enabled and active', [nav.exists, nav.disabled !== 'true', nav.active], [true, true, 'keyboardShortcuts']) const light = await read(layoutSource, settings) snapshots.light = light check('panel keeps the recovered 638x620 box at 232,40', [near(light.panel.x, 232), near(light.panel.y, 40), near(light.panel.width, 638), near(light.panel.height, 620)], [true, true, true, true]) check('toolbar/search/restore keep the recovered 638x32, 516x32 and 110x32 geometry', [[near(light.toolbar.width, 638), near(light.toolbar.height, 32)].every(Boolean), [near(light.search.width, 516), near(light.search.height, 32)].every(Boolean), [near(light.restore.x, 760), near(light.restore.width, 110), near(light.restore.height, 32)].every(Boolean)], [true, true, true]) check('全部恢复默认 stays disabled like the original', light.restoreDisabled, true) check('list/header/scroll keep 638x572, 39 and 638x533 at 232,88/127', [[near(light.list.x, 232), near(light.list.y, 88), near(light.list.width, 638), near(light.list.height, 572)].every(Boolean), near(light.header.height, 39), [near(light.scroll.y, 127), near(light.scroll.height, 533)].every(Boolean)], [true, true, true]) check('column headers read 命令 / 按键绑定 / 操作', light.headerTexts, ['命令', '按键绑定', '操作']) check('sheet renders the recovered 24 rows in order', light.rows.map(row => row.label), EXPECTED_LABELS) check('every row keeps the recovered key binding text', light.rows.map(row => row.keys.join('+') || null), EXPECTED_KEYS) check('row heights follow the recovered 41 / 45 / 44 pattern', light.rows.map((row, index) => index < 16 ? near(row.rect.height, 41) : index < 23 ? near(row.rect.height, 45) : near(row.rect.height, 44)), Array.from({ length: 24 }, () => true)) check('keycaps stay 22px high', light.rows.every(row => near(row.keyRect.height, 22)), true) check('first 16 rows expose the delete action and stay editable', light.rows.slice(0, 16).every(row => row.hasDelete && !row.notEditable), true) check('last 8 rows are read-only with an em dash placeholder', light.rows.slice(16).every(row => !row.hasDelete && row.notEditable && row.placeholder === '—'), true) check('语音录制开关 keeps the plain 长按 prefix before the Alt key', [light.rows[2].prefix, light.rows[2].keys], ['长按', ['Alt']]) check('supported rows match the shortcuts YCode really binds', light.rows.filter(row => row.supported === 'true').map(row => row.id), SUPPORTED_IDS) check('unsupported rows are labelled as not wired in their tooltip', light.rows.filter(row => row.supported === 'false').every(row => (row.title ?? '').includes('尚未接入')), true) await step('shortcuts-search') await read(`(()=>{const input=document.querySelector('.keyboard-shortcuts-panel .wb-input__inner') const setter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set setter.call(input,'语音'); input.dispatchEvent(new Event('input',{bubbles:true})); return true})()`, settings) const filtered = await wait(`(()=>{const rows=[...document.querySelectorAll('.shortcut-item')] return rows.length===1?rows.map(row=>row.querySelector('.shortcut-item__label').textContent.trim()):null})()`, 'filtered to one row', settings) check('search 语音 filters the sheet to 语音录制开关', filtered, ['语音录制开关']) await read(`(()=>{const input=document.querySelector('.keyboard-shortcuts-panel .wb-input__inner') const setter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set setter.call(input,'zzzz'); input.dispatchEvent(new Event('input',{bubbles:true})); return true})()`, settings) const empty = await wait(`document.querySelector('.keyboard-shortcuts-panel__empty')?.textContent?.trim()`, 'empty state', settings) check('a query without matches shows the empty state', empty, '未找到匹配的快捷键') await read(`(()=>{const input=document.querySelector('.keyboard-shortcuts-panel .wb-input__inner') const setter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set setter.call(input,''); input.dispatchEvent(new Event('input',{bubbles:true})); return true})()`, settings) await wait(`document.querySelectorAll('.shortcut-item').length===24`, 'full sheet restored', settings) check('clearing the query restores all 24 rows', await read(`document.querySelectorAll('.shortcut-item').length`, settings), 24) await step('shortcuts-recording') await read(`document.querySelector('[data-shortcut-id="global-search-open"] .shortcut-item__binding-tooltip-anchor').click()`, settings) const recording = await wait(`(()=>{const row=document.querySelector('[data-shortcut-id="global-search-open"]') if(!row?.classList.contains('shortcut-item--recording'))return null return { text: row.querySelector('.shortcut-item__recording-text')?.textContent?.trim(), cancel: row.querySelector('.shortcut-item__esc-cancel')?.textContent?.trim(), cursor: !!row.querySelector('.shortcut-item__recording-cursor') }})()`, 'recording state', settings) snapshots.recording = recording check('clicking a binding enters the recovered 按下快捷键 / ESC 取消 recording state', [recording.text, recording.cancel, recording.cursor], ['按下快捷键', 'ESC 取消', true]) await sendKey('Escape', 'Escape', 27) const cancelled = await wait(`(()=>{const row=document.querySelector('[data-shortcut-id="global-search-open"]') if(!row||row.classList.contains('shortcut-item--recording'))return null return row.querySelector('.shortcut-item__key')?.textContent?.trim()})()`, 'recording cancelled', settings) check('Escape cancels recording and keeps the original Ctrl+K binding', cancelled, 'Ctrl+K') await read(`document.querySelector('[data-shortcut-id="global-search-open"] .shortcut-item__binding-tooltip-anchor').click()`, settings) await wait(`!!document.querySelector('.shortcut-item--recording')`, 'recording again', settings) await sendKey('a', 'KeyA', 65) const errorHint = await wait(`document.querySelector('.shortcut-item__error-hint')?.textContent?.trim()`, 'edit unsupported hint', settings) check('capturing a key reports that custom bindings are not wired and keeps the default', [errorHint, await read(`document.querySelector('[data-shortcut-id="global-search-open"] .shortcut-item__key')?.textContent?.trim()`, settings)], ['自定义键位尚未接入,已保留默认绑定', 'Ctrl+K']) await step('shortcuts-dark-theme') await openSettings('appearance') await wait('!!document.querySelector(".appearance-card")', 'appearance panel', settings) await read(`document.querySelector('[aria-label="深色"].appearance-card').click()`, settings) await wait(`document.documentElement.dataset.theme==='dark'`, 'dark theme', settings) await openSettings('keyboardShortcuts') await wait(`document.documentElement.dataset.theme==='dark'&&!!document.querySelector('.keyboard-shortcuts-panel')`, 'dark shortcuts panel', settings) const dark = await read(layoutSource, settings) snapshots.dark = dark check('dark theme keeps every recovered geometry', [near(dark.panel.width, 638), near(dark.panel.height, 620), near(dark.list.height, 572), near(dark.header.height, 39)], [true, true, true, true]) check('dark rows use the recovered 31,31,31 surface with light text', [dark.rows.every(row => row.rowBackground === 'rgb(31, 31, 31)'), dark.rows.every(row => row.labelColor === 'rgb(242, 243, 245)')], [true, true]) check('dark keycaps use the recovered 8% white fill', dark.rows.every(row => /^color\(srgb 1 1 1 \/ 0\.08\)$/.test(row.keyBackground) || row.keyBackground === 'rgba(255, 255, 255, 0.08)'), true) await step('shortcuts-restore-light') await openSettings('appearance') await wait('!!document.querySelector(".appearance-card")', 'appearance panel again', settings) await read(`document.querySelector('[aria-label="浅色"].appearance-card').click()`, settings) await wait(`document.documentElement.dataset.theme==='light'`, 'light theme restored', settings) await openSettings('keyboardShortcuts') const restored = await read(layoutSource, settings) check('selecting 浅色 restores the light sheet', [restored.theme, restored.rows.length, restored.rows[0].rowBackground], ['light', 24, 'rgb(255, 255, 255)']) await step('shortcuts-errors') check('no renderer error logs', rendererErrors, []) const result = checks.every(item => item.pass) ? 'PASS' : 'FAIL' await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result, phase, checks, snapshots }, null, 2)) console.log('SHORTCUTS_SETTINGS_' + result + ' ' + directory) for (const failure of checks.filter(item => !item.pass)) console.log('SHORTCUTS_SETTINGS_FAILURE ' + JSON.stringify(failure)) app.exit(result === 'PASS' ? 0 : 1) })().catch(async error => { console.error(error) try { await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'ERROR', phase, checks, snapshots, error: String(error?.stack ?? error) }, null, 2)) } catch { /* ignore */ } app.exit(1) }) }