@@ -0,0 +1,288 @@
// 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 _OMP _HOME : home ,
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 )
} )
}