// TODO 14 acceptance: WorkBuddy 5.8.3 memory settings, live YCode IPC, import flow and themes. // Real isolated Electron, real preload/IPC/renderer/filesystem, viewport 900x680. const fs = require('node:fs/promises') const path = require('node:path') const assert = require('node:assert/strict') const { createHash } = require('node:crypto') if (!process.versions.electron) { ;(async () => { const directory = await fs.mkdtemp(path.resolve('.tmp/memory-settings-')) const data = path.join(directory, 'data') const 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('MEMORY_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(-5000)) 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 = [] const snapshots = {} const rendererErrors = [] let main let settings let phase = 'boot' const pause = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)) async function read(source, win = settings || 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 { if (timer) clearTimeout(timer) } } async function wait(source, label, win = settings || main) { for (let index = 0; index < 350; 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 = 1) => Math.abs(value - target) <= tolerance async function readJson(file) { let lastError for (let index = 0; index < 80; index++) { try { const text = await fs.readFile(file, 'utf8') if (text.trim()) return JSON.parse(text) } catch (error) { lastError = error } await pause(25) } throw lastError ?? Error(`JSON file stayed empty: ${file}`) } async function waitForJson(file, predicate, label) { let lastValue for (let index = 0; index < 350; index++) { try { lastValue = await readJson(file) if (predicate(lastValue)) return lastValue } catch (error) { lastValue = error } await pause(40) } throw Error(`Timeout: ${label}; last value: ${JSON.stringify(lastValue)}`) } 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)})`, main) 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 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 style = node => node ? getComputedStyle(node) : null const panel = document.querySelector('.memory-settings-panel') const group = document.querySelector('.memory-settings-panel__generate-memory-group') const rows = [...document.querySelectorAll('.memory-settings-panel__setting-row')] const switches = [...document.querySelectorAll('.memory-settings-panel .wb-switch__input')].map(input => { const label = input.closest('.wb-switch') const track = label?.querySelector('.wb-switch__track') const thumb = label?.querySelector('.wb-switch__thumb') return { checked: input.checked, disabled: input.disabled, label: input.getAttribute('aria-label'), track: rect(track), thumb: rect(thumb), trackBackground: style(track)?.backgroundColor, thumbBackground: style(thumb)?.backgroundColor, title: label?.getAttribute('title') } }) const manage = document.querySelector('.memory-settings-panel__manage-section') const name = document.querySelector('.memory-settings-panel__setting-name') const desc = document.querySelector('.memory-settings-panel__setting-desc') const privacy = document.querySelector('.memory-settings-panel__privacy') const link = document.querySelector('.memory-settings-panel__privacy-link') return { theme: document.documentElement.dataset.theme ?? 'light', panel: rect(panel), group: rect(group), rows: rows.map(rect), switches, manage: rect(manage), titles: [...document.querySelectorAll('.memory-settings-panel__setting-name')].map(node => node.textContent.trim()), descriptions: [...document.querySelectorAll('.memory-settings-panel__setting-desc')].map(node => node.textContent.trim()), manageTitle: document.querySelector('.memory-modal__detail-name')?.textContent?.trim(), manageDesc: document.querySelector('.memory-modal__detail-desc')?.textContent?.trim(), importButton: (() => { const node = document.querySelector('.memory-settings-panel__manage-section .wb-button') return node ? { text: node.textContent.trim(), rect: rect(node), background: style(node).backgroundColor, border: style(node).borderTopColor } : null })(), privacy: rect(privacy), privacyText: privacy?.textContent?.replace(/\\s+/g, ' ').trim(), link: link ? { href: link.getAttribute('href'), title: link.getAttribute('title'), ariaDisabled: link.getAttribute('aria-disabled') } : null, titleStyle: name ? { fontSize: style(name).fontSize, fontWeight: style(name).fontWeight, lineHeight: style(name).lineHeight, color: style(name).color } : null, descStyle: desc ? { fontSize: style(desc).fontSize, fontWeight: style(desc).fontWeight, lineHeight: style(desc).lineHeight, color: style(desc).color } : null, groupBackground: style(group)?.backgroundColor, manageBackground: style(manage)?.backgroundColor, panelBackground: style(panel)?.backgroundColor, privacyColor: style(privacy)?.color, content: document.querySelector('.memory-settings-panel .memory-modal__memory-text')?.textContent ?? '' } })()` const importLayoutSource = `(()=>{ 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 style = node => node ? getComputedStyle(node) : null const root = document.querySelector('[data-memory-import-dialog]') const dialog = root?.querySelector('.wb-modal--medium') const sections = [...(root?.querySelectorAll('.memory-modal__section') ?? [])] const prompt = root?.querySelector('.memory-modal__prompt-text') const textarea = root?.querySelector('textarea') const buttons = [...(root?.querySelectorAll('.wb-modal__footer .wb-button') ?? [])] return root ? { dialog: rect(dialog), title: dialog?.querySelector('.wb-modal__title')?.textContent?.trim(), dialogBackground: style(dialog)?.backgroundColor, dialogRadius: style(dialog)?.borderRadius, sections: sections.map(rect), labels: sections.map(section => section.querySelector('.memory-modal__label')?.textContent?.trim()), prompt: prompt?.textContent ?? '', promptHash: null, textarea: textarea ? { rect: rect(textarea), placeholder: textarea.getAttribute('placeholder'), value: textarea.value } : null, buttons: buttons.map(button => ({ text: button.textContent.trim(), disabled: button.disabled, className: button.className, rect: rect(button) })), error: root.querySelector('[role=alert]')?.textContent?.trim() ?? '' } : null })()` const resetLayoutSource = `(()=>{ 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 style = node => node ? getComputedStyle(node) : null const root = document.querySelector('[data-memory-reset-dialog]') const dialog = root?.querySelector('.memory-reset-modal') const body = dialog?.querySelector('.wb-modal__body') const buttons = [...(dialog?.querySelectorAll('.wb-modal__footer .wb-button') ?? [])] return root ? { dialog: rect(dialog), title: dialog.querySelector('.wb-modal__title')?.textContent?.trim(), description: body?.querySelector('.memory-modal__reset-confirm-desc')?.textContent?.trim(), hint: body?.querySelector('.memory-modal__reset-confirm-hint')?.textContent?.trim(), background: style(dialog)?.backgroundColor, radius: style(dialog)?.borderRadius, closeLabel: dialog.querySelector('.wb-modal__close')?.getAttribute('aria-label'), focused: document.activeElement?.textContent?.trim(), buttons: buttons.map(button => ({ text: button.textContent.trim(), disabled: button.disabled, danger: button.classList.contains('wb-button--danger'), background: style(button)?.backgroundColor, border: style(button)?.borderTopColor, rect: rect(button) })) } : null })()` ;(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', main) await step('memory-base-light') await openSettings('memory') observe(settings) await wait('!!document.querySelector(".memory-settings-panel")', 'memory panel', settings) const light = await read(layoutSource, settings) snapshots.light = light await fs.writeFile(path.join(directory, 'ycode-memory-light.png'), (await settings.webContents.capturePage()).toPNG()) const nav = await read(`(()=>{const item=document.querySelector('[data-settings-tab="memory"]') return { exists: !!item, disabled: item?.getAttribute('aria-disabled'), active: document.querySelector('.settings-navigation__item--active')?.dataset.settingsTab }})()`, settings) check('memory navigation entry is enabled and active', [nav.exists, nav.disabled !== 'true', nav.active], [true, true, 'memory']) check('memory 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('two setting rows keep the recovered 638x128 group and 606x46 rows', [near(light.group.x, 232), near(light.group.y, 40), near(light.group.width, 638), near(light.group.height, 128), light.rows.map(row => [near(row.x, 248), near(row.width, 606), near(row.height, 46)].every(Boolean)), near(light.rows[0].y, 52), near(light.rows[1].y, 110)], [true, true, true, true, [true, true], true, true]) check('memory copy and local-skills limitation are aligned', [light.titles, light.descriptions, light.switches[1].title], [['对话记忆', '本地记忆与技能'], ['记住对话中的重要信息,让后续回答更贴合你。', '自动记录工作过程并优化技能,数据仅保存在你的设备上。'], '仅保存偏好;本地工作过程自动记录执行器尚未接入']) check('switches keep recovered geometry and source default state', light.switches.map(item => [item.checked, item.disabled, near(item.track.width, 34), near(item.track.height, 20), near(item.thumb.width, 16), near(item.thumb.height, 16), item.trackBackground]), [[true, false, true, true, true, true, 'rgb(0, 194, 154)'], [false, false, true, true, true, true, 'rgb(230, 230, 230)']]) check('manage card, typography and privacy footer keep recovered geometry', [near(light.manage.x, 232), near(light.manage.y, 184), near(light.manage.width, 638), near(light.manage.height, 78), light.manageTitle, light.manageDesc, light.titleStyle, light.descStyle, near(light.privacy.x, 232), near(light.privacy.y, 638), near(light.privacy.width, 638), near(light.privacy.height, 22), light.privacyColor], [true, true, true, true, '管理记忆', '每晚自动整理更新,可随时查看和编辑。', { fontSize: '14px', fontWeight: '500', lineHeight: '22px', color: 'rgb(0, 0, 0)' }, { fontSize: '14px', fontWeight: '400', lineHeight: '22px', color: 'rgba(0, 0, 0, 0.7)' }, true, true, true, true, 'rgba(0, 0, 0, 0.7)']) check('privacy legal target is visibly present but has no external URL', [light.privacyText, light.link.href, light.link.title, light.link.ariaDisabled], ['记忆内容遵循 YCode 隐私政策,仅你本人可见', null, '尚未配置', 'true']) await step('memory-switch-persistence') await read(`document.querySelectorAll('.memory-settings-panel .wb-switch__input')[0].click()`, settings) await wait(`document.querySelectorAll('.memory-settings-panel .wb-switch__input')[0]?.checked===false`, 'conversation memory off', settings) let config = await readJson(path.join(directory, 'data', 'config.json')) check('conversation-memory preference persists without changing local memory', [config.memoryPreferences?.conversationMemory, config.memoryPreferences?.localMemorySkills], [false, false]) settings.close() await pause(500) await openSettings('memory') await wait('!!document.querySelector(".memory-settings-panel")', 'memory panel reopened', settings) const reopened = await read(layoutSource, settings) check('reopened settings restores both independent preferences', [reopened.switches[0].checked, reopened.switches[1].checked], [false, false]) await read(`document.querySelectorAll('.memory-settings-panel .wb-switch__input')[0].click()`, settings) await wait(`document.querySelectorAll('.memory-settings-panel .wb-switch__input')[0]?.checked===true`, 'conversation memory restored', settings) await wait(`document.querySelectorAll('.memory-settings-panel .wb-switch__input')[1]?.disabled===false`, 'memory switches idle', settings) config = await readJson(path.join(directory, 'data', 'config.json')) check('conversation-memory round trip still leaves local memory disabled', [config.memoryPreferences?.conversationMemory, config.memoryPreferences?.localMemorySkills], [true, false]) const enabledLocalSkills = await read(`(()=>{ const input=document.querySelectorAll('.memory-settings-panel .wb-switch__input')[1] if(!(input instanceof HTMLInputElement)||input.disabled)return false input.click() return true })()`, settings) assert.equal(enabledLocalSkills, true, 'local memory switch must be enabled before the real click') await wait(`(()=>{ const panel=document.querySelector('.memory-settings-panel') const input=document.querySelectorAll('.memory-settings-panel .wb-switch__input')[1] return panel?.getAttribute('aria-busy')==='false'&&input?.checked===true&&input?.disabled===false })()`, 'local skills on IPC complete', settings) config = await waitForJson( path.join(directory, 'data', 'config.json'), value => value.memoryPreferences?.localMemorySkills === true, 'local memory and skills preference persisted' ) check('local memory and skills preference persists', config.memoryPreferences?.localMemorySkills, true) const disabledLocalSkills = await read(`(()=>{ const input=document.querySelectorAll('.memory-settings-panel .wb-switch__input')[1] if(!(input instanceof HTMLInputElement)||input.disabled)return false input.click() return true })()`, settings) assert.equal(disabledLocalSkills, true, 'local memory switch must be enabled before restoring') await wait(`(()=>{ const panel=document.querySelector('.memory-settings-panel') const input=document.querySelectorAll('.memory-settings-panel .wb-switch__input')[1] return panel?.getAttribute('aria-busy')==='false'&&input?.checked===false&&input?.disabled===false })()`, 'local skills restored to source default', settings) await waitForJson( path.join(directory, 'data', 'config.json'), value => value.memoryPreferences?.localMemorySkills === false, 'local memory and skills preference restored' ) await step('memory-import-dialog') await read(`document.querySelector('.memory-settings-panel__manage-section .wb-button').click()`, settings) await wait('!!document.querySelector("[data-memory-import-dialog]")', 'import dialog', settings) const dialog = await read(importLayoutSource, settings) snapshots.importDialog = dialog await fs.writeFile(path.join(directory, 'ycode-memory-import.png'), (await settings.webContents.capturePage()).toPNG()) const promptHash = createHash('sha256').update(dialog.prompt).digest('hex') check('import dialog keeps the recovered 640x520 centered shell', [near(dialog.dialog.x, 130), near(dialog.dialog.y, 80), near(dialog.dialog.width, 640), near(dialog.dialog.height, 520), dialog.dialogRadius], [true, true, true, true, '24px']) check('import dialog keeps the recovered title, steps and placeholder', [dialog.title, dialog.labels, dialog.textarea.placeholder], ['导入其他记忆', ['复制以下提示词到其他AI对话中', '将结果粘贴到下方,添加到 YCode 记忆'], '在此粘贴你的记忆']) check('import prompt exactly matches the REA-recovered zh-CN template', [dialog.prompt.length, Buffer.byteLength(dialog.prompt), promptHash], [593, 1657, 'b9142225cbb0b036168e860077c7d8bdb0165f449e00772af55429b8673d632d']) check('import footer keeps the recovered 68x32 action pair', dialog.buttons.map(button => [button.text, near(button.rect?.width ?? 68, 68)]), [['取消', true], ['导入', true]]) await step('memory-import-validation') await read(`(()=>{const field=document.querySelector('[data-memory-import-dialog] textarea') const setter=Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set setter.call(field,'内容过短') field.dispatchEvent(new Event('input',{bubbles:true}))})()`, settings) await pause(80) await read(`document.querySelectorAll('[data-memory-import-dialog] .wb-modal__footer .wb-button')[1].click()`, settings) await wait(`document.querySelector('[data-memory-import-dialog] [role=alert]')?.textContent?.includes('内容过短')`, 'short memory error', settings) check('short import content is rejected with recovered copy', await read(`document.querySelector('[data-memory-import-dialog] [role=alert]').textContent.trim()`, settings), '内容过短,请粘贴完整的记忆内容') await step('memory-import-write') const imported = '**偏好**\n[2026-10-17] - 用户偏好 TypeScript 和简洁的实现。' await read(`(()=>{const field=document.querySelector('[data-memory-import-dialog] textarea') const setter=Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set setter.call(field,${JSON.stringify(imported)}) field.dispatchEvent(new Event('input',{bubbles:true}))})()`, settings) await pause(80) await read(`document.querySelectorAll('[data-memory-import-dialog] .wb-modal__footer .wb-button')[1].click()`, settings) await wait(`!document.querySelector('[data-memory-import-dialog]')`, 'import dialog closes', settings) const importedLayout = await wait(`(()=>{const node=document.querySelector('.memory-settings-panel .memory-modal__memory-text') return node?{content:node.textContent,manage:document.querySelector('.memory-settings-panel__manage-section').getBoundingClientRect().height}:null})()`, 'imported memory visible', settings) config = await readJson(path.join(directory, 'data', 'config.json')) check('valid import writes to the same long-term memory used by conversations', [importedLayout.content.includes('用户偏好 TypeScript'), config.personalization?.longTermMemory], [true, imported]) check('manage card expands after content exists while retaining the viewport footer', [importedLayout.manage > 78, near((await read(layoutSource, settings)).privacy.y, 638)], [true, true]) await step('memory-dark') 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('memory') await wait(`document.documentElement.dataset.theme==='dark'&&!!document.querySelector('.memory-settings-panel')`, 'memory dark', settings) const dark = await read(layoutSource, settings) snapshots.dark = dark await fs.writeFile(path.join(directory, 'ycode-memory-dark.png'), (await settings.webContents.capturePage()).toPNG()) check('dark theme keeps recovered geometry and paints card/text with shared tokens', [near(dark.panel.width, 638), near(dark.panel.height, 620), near(dark.group.height, 128), dark.groupBackground, dark.titleStyle.color, dark.descStyle.color, dark.switches[0].trackBackground], [true, true, true, 'rgb(31, 31, 31)', 'rgb(242, 243, 245)', 'rgb(242, 243, 245)', 'rgb(34, 203, 168)']) 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('memory-reset-dialog') await openSettings('memory') await wait(`!!document.querySelector('.memory-settings-panel .memory-modal__memory-text')`, 'memory content before reset', settings) const openReset = `(()=>{const button=[...document.querySelectorAll('.memory-settings-panel__manage-section .wb-button')] .find(node=>node.textContent.trim()==='重置'); button?.click(); return !!button})()` check('reset action is present after memory content exists', await read(openReset, settings), true) await wait(`!!document.querySelector('[data-memory-reset-dialog]')`, 'reset confirmation dialog', settings) const resetDialog = await read(resetLayoutSource, settings) snapshots.resetDialog = resetDialog await fs.writeFile(path.join(directory, 'ycode-memory-reset.png'), (await settings.webContents.capturePage()).toPNG()) check('reset dialog keeps the recovered 480px WorkBuddy confirm shell', [near(resetDialog.dialog.width, 480), resetDialog.title, resetDialog.closeLabel, resetDialog.radius], [true, '重置记忆', '关闭重置记忆', '24px']) check('reset dialog keeps recovered destructive and recovery copy', [resetDialog.description, resetDialog.hint], ['此操作将清空当前全部记忆,该操作不可恢复。', '若「生成对话记忆」处于开启状态,后续对话仍会生成新的记忆;如需彻底停用,请同时关闭记忆功能。']) check('reset dialog exposes cancel and danger confirm actions with cancel initially focused', [resetDialog.focused, resetDialog.buttons.map(button => [button.text, button.danger]), resetDialog.buttons.map(button => button.text === '取消' ? near(button.rect.width, 68) : button.rect.width >= 66)], ['取消', [['取消', false], ['重置记忆', true]], [true, true]]) await read(`document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}))`, settings) await wait(`!document.querySelector('[data-memory-reset-dialog]')`, 'reset dialog closes with Escape', settings) await read(openReset, settings) await wait(`!!document.querySelector('[data-memory-reset-dialog]')`, 'reset dialog reopened', settings) await read(`document.querySelectorAll('[data-memory-reset-dialog] .wb-modal__footer .wb-button')[1].click()`, settings) await wait(`!document.querySelector('[data-memory-reset-dialog]')&&!document.querySelector('.memory-settings-panel .memory-modal__memory-text')`, 'memory reset', settings) config = await readJson(path.join(directory, 'data', 'config.json')) const resetLayout = await read(layoutSource, settings) check('reset clears the same long-term memory and restores the collapsed manage card', [config.personalization?.longTermMemory, resetLayout.content, near(resetLayout.manage.height, 78)], ['', '', true]) await step('memory-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(`MEMORY_SETTINGS_${result} ${directory}`) for (const failure of checks.filter(item => !item.pass)) { console.log(`MEMORY_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 { /* Keep original failure. */ } app.exit(1) }) }