// TODO 13 验收:设置 → 个性化页的 1:1 结构/几何 + 身份编辑/回复风格/开关/长文本弹窗 + 持久化与主题。 // 真实隔离 YCode(真实 preload/IPC/渲染进程/文件系统)+ 原生设置窗口,视口 900×680(与原版一致)。 // // 原版依据(REA ev_af920…/ev_88af07…/ev_d6e0a2…/ev_523e07…/ev_4f4f76…): // 面板 [232,40,638,1160];身份卡 638×100(两行 [244,48,614,34]、胶囊 172×34); // 回复偏好卡 638×204(回复风格 select 120×32 @734,174;开关 34×20 @820,248/316); // 指令/人设/长期记忆三张长卡 638×264(y=376/656/936),预览 598×160;编辑弹窗 [130,80,640,520]。 const fs = require('node:fs/promises') const path = require('node:path') const assert = require('node:assert/strict') const TONE_LABELS = ['默认', '专业严谨', '亲和友善', '直言不讳', '天马行空', '高效务实', '毒舌吐槽', '启发引导'] const TONE_HINTS = ['不设定特定风格。', '清晰、准确、值得信赖。', '温暖、平易近人、鼓励支持。', '简明扼要、不废话、直击要点。', '富有想象力、善用比喻类比。', '最少文字、最大信息量。', '犀利吐槽、但绝不伤人。', '用提问引导思考、授人以渔。'] if (!process.versions.electron) { ;(async () => { const directory = await fs.mkdtemp(path.resolve('.tmp/personalization-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('PERSONALIZATION_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(400) return settings } const setInput = (selector, value) => `(()=>{const input=document.querySelector(${JSON.stringify(selector)}) const proto=input instanceof HTMLTextAreaElement?HTMLTextAreaElement.prototype:HTMLInputElement.prototype Object.getOwnPropertyDescriptor(proto,'value').set.call(input,${JSON.stringify(value)}) input.dispatchEvent(new Event('input',{bubbles:true}));return true})()` 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('.personalization-panel') return { panel: rect(panel), identityCard: rect(document.querySelector('.local-memory-identity-card')), rows: [...document.querySelectorAll('.local-memory-identity-row')].map(row => ({ id: row.id, label: row.querySelector('.local-memory-identity-row__label')?.textContent?.trim(), rect: rect(row), pill: (() => { const node = row.querySelector('.local-memory-pill') return node ? { rect: rect(node), text: node.textContent.trim(), placeholder: !!node.querySelector('.local-memory-pill__placeholder') } : null })(), input: (() => { const node = row.querySelector('.local-memory-inline__input') return node ? { rect: rect(node), value: node.value } : null })(), actions: row.querySelectorAll('.local-memory-inline__icon-btn').length, confirmDisabled: row.querySelector('.local-memory-inline__icon-btn--confirm')?.disabled ?? null })), preference: rect(document.querySelector('.general-settings-panel__group-card')), sections: [...document.querySelectorAll('.general-settings-panel__group-card > .general-settings-panel__section')].map(section => ({ id: section.id, title: section.querySelector('.general-settings-panel__section-title')?.textContent?.trim(), desc: section.querySelector('.general-settings-panel__section-desc')?.textContent?.trim(), rect: rect(section), select: rect(section.querySelector('.wb-select')), switch: rect(section.querySelector('.wb-switch__track')), switchChecked: section.querySelector('.wb-switch__input')?.checked ?? null, switchDisabled: section.querySelector('.wb-switch__input')?.disabled ?? null, unavailable: section.querySelector('[data-settings-unavailable=true]')?.getAttribute('title') })), longCards: [...document.querySelectorAll('.local-memory-long-card')].map(card => ({ id: card.id, rect: rect(card), title: card.querySelector('.general-settings-panel__section-title')?.textContent?.trim(), desc: card.querySelector('.general-settings-panel__section-desc')?.textContent?.trim(), edit: rect(card.querySelector('.general-settings-panel__section-action .wb-button')), preview: rect(card.querySelector('.local-memory-long__preview')), text: card.querySelector('.local-memory-long__value,.local-memory-long__placeholder')?.textContent?.trim(), placeholder: !!card.querySelector('.local-memory-long__placeholder') })), toneLabel: document.querySelector('.personalization-panel__tone-trigger .wb-select__value')?.textContent?.trim(), popover: rect(document.querySelector('.personalization-panel__tone-popover')), options: [...document.querySelectorAll('.personalization-panel__tone-popover [role=option]')].map(option => ({ name: option.querySelector('.personalization-panel__option-name')?.textContent?.trim(), hint: option.querySelector('.personalization-panel__option-hint')?.textContent?.trim(), selected: option.getAttribute('aria-selected') })), modal: rect(document.querySelector('.local-memory-edit-modal')), modalTitle: document.querySelector('.local-memory-edit-modal .wb-modal__title')?.textContent?.trim(), editorValue: document.querySelector('.local-memory-editor')?.value ?? null, editorPlaceholder: document.querySelector('.local-memory-editor')?.getAttribute('placeholder') ?? null, modalButtons: [...document.querySelectorAll('.local-memory-edit-modal .wb-modal__footer .wb-button')].map(button => ({ text: button.textContent.trim(), rect: rect(button) })), rowBackground: getComputedStyle(document.querySelector('.local-memory-identity-card')).backgroundColor, rowColor: getComputedStyle(document.querySelector('.local-memory-identity-row__label')).color, loadingTips: localStorage.getItem('ycode:loading-tips'), 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('open-personalization-settings') await openSettings('personalization'); observe(settings) await wait('!!document.querySelector(".personalization-panel")', 'personalization panel', settings) const nav = await read(`(()=>{const item=document.querySelector('[data-settings-tab="personalization"]') 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, 'personalization']) const light = await read(layoutSource, settings) snapshots.light = light check('panel keeps the recovered 638x1160 box at 232,40', [near(light.panel.x, 232), near(light.panel.y, 40), near(light.panel.width, 638), near(light.panel.height, 1160)], [true, true, true, true]) check('identity card keeps 638x100 with two 614x34 rows', [near(light.identityCard.width, 638), near(light.identityCard.height, 100), near(light.rows[0].rect.y, 48), near(light.rows[0].rect.width, 614), near(light.rows[0].rect.height, 34), near(light.rows[1].rect.y, 98)], [true, true, true, true, true, true]) check('identity rows keep the recovered labels and empty pills', [light.rows.map(row => row.label), light.rows.every(row => row.pill?.text === '暂无内容,点击编辑添加' && row.pill?.placeholder), light.rows.every(row => row.pill && near(row.pill.rect.x, 686) && near(row.pill.rect.width, 172) && near(row.pill.rect.height, 34))], [['YCode 对你的称呼', 'YCode 的名字'], true, true]) check('reply preference card keeps 638x204 with three recovered rows', [near(light.preference.y, 156), near(light.preference.width, 638), near(light.preference.height, 204), light.sections.map(section => section.title)], [true, true, true, ['回复风格', '欢迎语', '显示文件修改过程']]) check('reply style select keeps 120x32 and the default tone', [near(light.sections[0].select.x, 734), near(light.sections[0].select.y, 174), near(light.sections[0].select.width, 120), near(light.sections[0].select.height, 32), light.toneLabel], [true, true, true, true, '默认']) check('switches keep 34x20 with 欢迎语 on and 显示文件修改过程 disabled', [[near(light.sections[1].switch.width, 34), near(light.sections[1].switch.height, 20), light.sections[1].switchChecked].every(Boolean), light.sections[2].switchChecked === false && light.sections[2].switchDisabled === true && light.sections[2].unavailable === '暂未接入'], [true, true]) check('three long cards keep 638x264 at y=376/656/936', light.longCards.map((card, index) => [near(card.rect.y, [376, 656, 936][index]), near(card.rect.width, 638), near(card.rect.height, 264)].every(Boolean)), [true, true, true]) check('long cards keep the recovered titles, descriptions and 598x160 previews', [light.longCards.map(card => card.title), light.longCards.map(card => card.desc), light.longCards.every(card => near(card.preview.width, 598) && near(card.preview.height, 160)), light.longCards.every(card => card.edit && near(card.edit.width, 68) && near(card.edit.height, 32))], [ ['给 YCode 的指令', '人设设定', '长期记忆'], ['写下你希望 YCode 一直遵守的要求,之后每次对话和任务都会默认生效。', '设定 YCode 的身份、性格与表达风格,对所有回复生效。', '跨会话保存的重要信息与结论,可随时查看和修改。'], true, true]) check('long previews keep their original content and placeholders', [light.longCards[0].placeholder, light.longCards[0].text, light.longCards[1].text?.startsWith("You're not a chatbot."), light.longCards[2].placeholder], [true, '例如:回答尽量简洁,先给结论', true, true]) await step('personalization-tone') await read(`document.querySelector('.personalization-panel__tone-trigger').click()`, settings) const popover = await wait(`(()=>{const node=document.querySelector('.personalization-panel__tone-popover') return node?{rect:(()=>{const r=node.getBoundingClientRect();return {x:r.x,width:r.width}})(),options:[...node.querySelectorAll('[role=option]')].length}:null})()`, 'tone popover', settings) check('回复风格 popover keeps the recovered 240px width and 8 options', [near(popover.rect.width, 240), popover.options], [true, 8]) const options = await read(`[...document.querySelectorAll('.personalization-panel__tone-popover [role=option]')].map(option => [ option.querySelector('.personalization-panel__option-name')?.textContent?.trim(), option.querySelector('.personalization-panel__option-hint')?.textContent?.trim()])`, settings) check('tone options keep every recovered label and hint', [options.map(item => item[0]), options.map(item => item[1])], [TONE_LABELS, TONE_HINTS]) await read(`[...document.querySelectorAll('.personalization-panel__tone-popover [role=option]')][1].click()`, settings) await wait(`document.querySelector('.personalization-panel__tone-trigger .wb-select__value')?.textContent?.trim()==='专业严谨'`, 'tone applied', settings) const toneStored = await read(`window.ycode.personalization.get().then(view => view.tone)`, settings) check('choosing 专业严谨 persists the tone', toneStored, 'professional') await step('personalization-switches') await read(`document.querySelector('#settings-loading-tips .wb-switch__input').click()`, settings) await wait(`localStorage.getItem('ycode:loading-tips')==='0'`, 'loading tips mirror', settings) check('欢迎语 switches off, persists and mirrors to the renderer', [await read(`window.ycode.personalization.get().then(view => view.loadingTips)`, settings), await read(`localStorage.getItem('ycode:loading-tips')`, settings)], [false, '0']) await read(`document.querySelector('#settings-loading-tips .wb-switch__input').click()`, settings) await wait(`localStorage.getItem('ycode:loading-tips')==='1'`, 'loading tips restored', settings) check('欢迎语 switches back on', await read(`window.ycode.personalization.get().then(view => view.loadingTips)`, settings), true) await read(`document.querySelector('#settings-file-changes .wb-switch__input').click()`, settings) check('显示文件修改过程 stays disabled and keeps the stored default', await read(`window.ycode.personalization.get().then(view => view.showFileChanges)`, settings), false) await step('personalization-identity') await read(`document.querySelector('#settings-local-memory-user-call-name .local-memory-pill').click()`, settings) const editing = await wait(`(()=>{const row=document.querySelector('#settings-local-memory-user-call-name') const input=row.querySelector('.local-memory-inline__input') if(!input)return null const r=input.getBoundingClientRect() return { width:r.width, height:r.height, actions:row.querySelectorAll('.local-memory-inline__icon-btn').length, confirmDisabled:row.querySelector('.local-memory-inline__icon-btn--confirm')?.disabled }})()`, 'identity editing', settings) check('clicking the pill opens the recovered 172x34 inline editor with cancel/confirm', [near(editing.width, 172), near(editing.height, 34), editing.actions, editing.confirmDisabled], [true, true, 2, true]) await read(setInput('#settings-local-memory-user-call-name .local-memory-inline__input', '小明'), settings) await pause(120) await read(`document.querySelector('#settings-local-memory-user-call-name .local-memory-inline__icon-btn--confirm').click()`, settings) const savedIdentity = await wait(`(()=>{const pill=document.querySelector('#settings-local-memory-user-call-name .local-memory-pill') return pill?pill.textContent.trim():null})()`, 'identity saved', settings) check('confirming a non-empty identity persists and renders it', [savedIdentity, await read(`window.ycode.personalization.get().then(view => view.userCallName)`, settings)], ['小明', '小明']) await read(`document.querySelector('#settings-local-memory-ai-name .local-memory-pill').click()`, settings) await wait(`!!document.querySelector('#settings-local-memory-ai-name .local-memory-inline__input')`, 'ai name editor', settings) await read(setInput('#settings-local-memory-ai-name .local-memory-inline__input', '临时草稿'), settings) // 真实焦点离开本行(点不可聚焦的 div 不会触发 blur,这里把焦点交给回复风格按钮)。 await read(`document.querySelector('.personalization-panel__tone-trigger').focus();true`, settings) const afterBlur = await wait(`(()=>{const row=document.querySelector('#settings-local-memory-ai-name') return row.querySelector('.local-memory-inline__input')?null:row.querySelector('.local-memory-pill')?.textContent?.trim()})()`, 'blur discards draft', settings) check('blurring outside the row discards the identity draft like the original', [afterBlur, await read(`window.ycode.personalization.get().then(view => view.aiName)`, settings)], ['暂无内容,点击编辑添加', '']) await step('personalization-editors') await read(`document.querySelector('#settings-local-memory-ai-name .local-memory-pill').click()`, settings) await wait(`!!document.querySelector('#settings-local-memory-ai-name .local-memory-inline__input')`, 'ai name editor again', settings) await read(setInput('#settings-local-memory-ai-name .local-memory-inline__input', '小助手'), settings) await pause(100) await read(`document.querySelector('#settings-local-memory-ai-name .local-memory-inline__icon-btn--confirm').click()`, settings) await wait(`document.querySelector('#settings-local-memory-ai-name .local-memory-pill')?.textContent?.trim()==='小助手'`, 'ai name saved', settings) await read(`document.querySelector('#settings-custom-prompt .local-memory-long__preview').click()`, settings) const modal = await wait(`(()=>{const node=document.querySelector('.local-memory-edit-modal') if(!node)return null const r=node.getBoundingClientRect() return { x:r.x, y:r.y, width:r.width, height:r.height, title:node.querySelector('.wb-modal__title')?.textContent?.trim(), placeholder:node.querySelector('.local-memory-editor')?.getAttribute('placeholder'), buttons:[...node.querySelectorAll('.wb-modal__footer .wb-button')].map(button=>({text:button.textContent.trim(),width:button.getBoundingClientRect().width,height:button.getBoundingClientRect().height})) }})()`, 'prompt editor modal', settings) snapshots.modal = modal check('指令 editor keeps the recovered 640x520 modal with 保存/取消 68x32 buttons', [[near(modal.x, 130), near(modal.y, 80), near(modal.width, 640), near(modal.height, 520)].every(Boolean), modal.title, modal.buttons.map(button => button.text), modal.buttons.every(button => near(button.width, 68) && near(button.height, 32))], [true, '给 YCode 的指令', ['取消', '保存'], true]) check('empty 指令 editor keeps the recovered placeholder', [modal.placeholder], ['当前内容为空,点击开始输入']) await read(setInput('.local-memory-editor', '先给结论,再给依据。'), settings) await pause(120) await read(`[...document.querySelectorAll('.local-memory-edit-modal .wb-modal__footer .wb-button')].find(button=>button.textContent.trim()==='保存').click()`, settings) await wait(`!document.querySelector('.local-memory-edit-modal')`, 'modal closed after save', settings) const promptCard = await read(`(()=>{const card=document.querySelector('#settings-custom-prompt') return { text:card.querySelector('.local-memory-long__value')?.textContent?.trim(), placeholder:!!card.querySelector('.local-memory-long__placeholder') }})()`, settings) check('saving the 指令 updates the preview and persists', [promptCard.text, promptCard.placeholder, await read(`window.ycode.personalization.get().then(view => view.customPrompt)`, settings)], ['先给结论,再给依据。', false, '先给结论,再给依据。']) await read(`document.querySelector('#settings-local-memory-soul .local-memory-long__preview').click()`, settings) const personaOpen = await wait(`(()=>{const node=document.querySelector('.local-memory-edit-modal') return node?{title:node.querySelector('.wb-modal__title')?.textContent?.trim(), value:node.querySelector('.local-memory-editor')?.value}:null})()`, 'persona editor', settings) check('人设 editor opens with the recovered default text', [personaOpen.title, personaOpen.value?.startsWith("You're not a chatbot.")], ['人设设定', true]) await read(`[...document.querySelectorAll('.local-memory-edit-modal .wb-modal__footer .wb-button')].find(button=>button.textContent.trim()==='取消').click()`, settings) await wait(`!document.querySelector('.local-memory-edit-modal')`, 'persona modal cancelled', settings) check('cancelling the persona editor keeps the stored default', await read(`window.ycode.personalization.get().then(view => view.persona.startsWith("You're not a chatbot."))`, settings), true) await step('personalization-persistence') const config = JSON.parse(await fs.readFile(path.join(directory, 'data', 'config.json'), 'utf8')) snapshots.config = config.personalization check('config.json keeps the identity, tone and prompt values', [config.personalization?.userCallName, config.personalization?.aiName, config.personalization?.tone, config.personalization?.customPrompt, config.personalization?.loadingTips], ['小明', '小助手', 'professional', '先给结论,再给依据。', true]) settings.close() await pause(500) await openSettings('personalization') const reopened = await read(layoutSource, settings) check('reopened settings restores the persisted personalization', [reopened.rows[0].pill?.text, reopened.rows[1].pill?.text, reopened.toneLabel, reopened.longCards[0].text], ['小明', '小助手', '专业严谨', '先给结论,再给依据。']) await step('personalization-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('personalization') await wait(`document.documentElement.dataset.theme==='dark'&&!!document.querySelector('.personalization-panel')`, 'dark personalization panel', settings) const dark = await read(layoutSource, settings) snapshots.dark = dark check('dark theme keeps the recovered card surface and text', [dark.rowBackground, dark.rowColor], ['rgb(31, 31, 31)', 'rgb(242, 243, 245)']) check('dark theme keeps every recovered geometry', [near(dark.panel.width, 638), near(dark.panel.height, 1160), near(dark.preference.height, 204), dark.longCards.every(card => near(card.preview.width, 598) && near(card.preview.height, 160))], [true, true, true, true]) 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 restored', settings) await step('personalization-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('PERSONALIZATION_SETTINGS_' + result + ' ' + directory) for (const failure of checks.filter(item => !item.pass)) console.log('PERSONALIZATION_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) }) }