Files
goodbuddy/tests/support/inline-help-driver.mjs
T
mesalogo 9836a4cc9d
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
fix: keep question order and streamline contextual help
Keep interactive questions and answered cards at their original position in
conversation history, including remote reception, recovery, and reopening.
Preserve legacy history placement without a database migration.

Move supplementary guidance into shared accessible contextual-help popovers
across pages and settings. Add keyboard, modal, accessibility, and Electron
regressions, plus bounded opt-in live canvas-analysis validation.

Remove unused Agent, Runtime, and in-memory graph-search helpers, obsolete
mocks and browser copy; reuse the existing package-size formatter. Keep
production execution paths, protocol schemas, and released data migrations.

Validation: targeted cleanup tests, typecheck, lint, shared Linux Host
attach/read/re-attach, and added help/Electron checks passed. Final full-suite
verification passed 4,858 tests with 67 skipped and zero failures (412 files
passed, 9 skipped). Focused help checks passed 54 tests, including Electron
style comparisons and Chromium accessibility descriptions. Details and
validation boundaries are recorded in feature docs.

Release note: 修复交互式问答在回答后排到后续输出末尾的问题,重新打开会话仍保留提问位置。页面和设置中的补充说明改为标题或字段旁的按需帮助,支持鼠标与键盘操作。
2026-09-20 01:26:36 +08:00

240 lines
16 KiB
JavaScript

import { app, BrowserWindow } from 'electron'
import assert from 'node:assert/strict'
import { join } from 'node:path'
import { mkdir, writeFile } from 'node:fs/promises'
import process from 'node:process'
import console from 'node:console'
import { setTimeout } from 'node:timers/promises'
app.setPath('userData', join(process.env.GOODBUDDY_HELP_DIRECTORY, 'profile'))
app.commandLine.appendSwitch('force-device-scale-factor', '1')
app.whenReady().then(async () => {
const win = new BrowserWindow({ show: true, width: 1000, height: 700, useContentSize: true,
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } })
win.webContents.on('console-message', event => { if (event.level === 'error') console.error(event.message) })
const js = async code => {
try { return await win.webContents.executeJavaScript(code) }
catch (error) { throw new Error('Renderer script: ' + code, { cause: error }) }
}
const settle = () => js('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))')
const wait = async code => {
for (let i = 0; i < 200; i++) {
if (await js(code)) return
await setTimeout(25)
}
throw new Error('Timeout: ' + code)
}
// scrollIntoView also scrolls overflow:hidden <details>, unlike a user's wheel.
const reveal = async selector => {
await js(`(() => { const e = document.querySelector(${JSON.stringify(selector)});
for (let p = e.parentElement; p; p = p.parentElement) {
if (!/(auto|scroll)/.test(getComputedStyle(p).overflowY)) continue;
const r = e.getBoundingClientRect(), bounds = p.getBoundingClientRect();
p.scrollTop += r.top + r.height/2 - bounds.top - p.clientHeight/2;
} })()`)
await settle()
}
const mouse = async (selector, click = false) => {
await reveal(selector)
const point = await js(`(() => { const e = document.querySelector(${JSON.stringify(selector)}); const r = e.getBoundingClientRect();
const x = Math.round(r.x + r.width / 2), y = Math.round(r.y + r.height / 2);
const parents = []; for (let p = e; p && parents.length < 9; p = p.parentElement) {
const b = p.getBoundingClientRect(); parents.push({ tag: p.tagName, class: p.className, inert: p.inert,
top: b.top, height: b.height, scrollTop: p.scrollTop, overflow: getComputedStyle(p).overflow, pointer: getComputedStyle(p).pointerEvents }); }
return { x, y, hit: e.contains(document.elementFromPoint(x,y)), target: document.elementFromPoint(x,y)?.outerHTML.slice(0, 250), parents }; })()`)
assert(point.hit, 'Occluded: ' + selector + ' ' + JSON.stringify(point))
win.webContents.sendInputEvent({ type: 'mouseMove', x: point.x, y: point.y })
if (click) {
win.webContents.sendInputEvent({ type: 'mouseDown', x: point.x, y: point.y, button: 'left', clickCount: 1 })
win.webContents.sendInputEvent({ type: 'mouseUp', x: point.x, y: point.y, button: 'left', clickCount: 1 })
}
await settle()
}
const key = async keyCode => {
win.webContents.sendInputEvent({ type: 'keyDown', keyCode })
win.webContents.sendInputEvent({ type: 'keyUp', keyCode })
await settle()
}
await win.loadURL(process.env.GOODBUDDY_HELP_URL)
await wait('!!document.querySelector("#open")')
win.focus()
win.webContents.focus()
await settle()
for (const theme of ['light', 'dark']) {
await js(`document.documentElement.dataset.theme = '${theme}'`)
for (const [width, height] of [[1000, 700], [360, 320]]) {
win.setContentSize(width, height)
await settle()
await mouse('#open', true)
await wait('!!document.querySelector("#outside")')
assert(await js('document.querySelector(".app-shell").inert'))
await mouse('[aria-label="Page"]')
await wait('!!document.querySelector("[role=tooltip]")')
assert(await js('document.activeElement.id === "outside"'), 'Hover stole focus')
assert(await js('document.querySelector("[aria-modal=true]").contains(document.querySelector("[role=tooltip]"))'))
await mouse('[role=tooltip]')
await setTimeout(160)
assert(await js('!!document.querySelector("[role=tooltip]")'), 'Hover transfer closed help')
await key('Escape')
await wait('!document.querySelector("[role=tooltip]")')
assert(await js('!!document.querySelector("[aria-modal=true]")'), `Hover Escape closed modal: ${theme} ${width}x${height}`)
await mouse('#outside')
await key('Tab')
await wait('!!document.querySelector("[role=tooltip]")')
assert(await js('document.activeElement.matches(".inline-help")'))
await key('Escape')
await key('Space')
await wait('!!document.querySelector("[role=tooltip]")')
await key('Space')
assert(await js('!document.querySelector("[role=tooltip]")'))
await mouse('[aria-label="Long help"]', true)
await wait('!!document.querySelector("#long-help[role=tooltip]")')
win.webContents.sendInputEvent({ type: 'mouseMove', x: 5, y: 5 })
await setTimeout(160)
assert(await js('!!document.querySelector("#long-help[role=tooltip]")'), 'Click did not pin help')
assert(await js(`(() => { const e = document.querySelector('#long-help'), r = e.getBoundingClientRect();
return r.left >= 15 && r.top >= 15 && r.right <= innerWidth - 15 && r.bottom <= innerHeight - 15 && e.scrollWidth <= e.clientWidth; })()`), 'Help overflowed viewport')
win.webContents.sendInputEvent({ type: 'mouseDown', x: 5, y: 5, button: 'left', clickCount: 1 })
win.webContents.sendInputEvent({ type: 'mouseUp', x: 5, y: 5, button: 'left', clickCount: 1 })
await settle()
assert(await js('!document.querySelector("[role=tooltip]")'))
await key('Escape')
await wait('!document.querySelector("[aria-modal=true]")')
assert(await js('document.activeElement.id === "open"'))
}
}
console.log('InlineHelp: hover/focus/click/Escape/outside/modal/narrow bounds passed in light and dark themes')
win.webContents.debugger.attach('1.3')
const evidence = []
const artifacts = process.env.GOODBUDDY_HELP_ARTIFACTS
if (artifacts) await mkdir(artifacts, { recursive: true })
let capturePrefix = ''
let captureIndex = 0
const snapshot = root => js(`(() => { const root = document.querySelector(${JSON.stringify(root)});
return JSON.stringify({ fields: Array.from(root.querySelectorAll('input, select, textarea')).map(e => [e.id, e.value, e.checked]),
details: Array.from(root.querySelectorAll('details')).map(e => e.open),
tabs: Array.from(root.querySelectorAll('[aria-selected], [aria-pressed]')).map(e => [e.textContent, e.getAttribute('aria-selected'), e.getAttribute('aria-pressed')]) }); })()`)
const description = async selector => {
const expected = await js(`(() => { const e = document.querySelector(${JSON.stringify(selector)});
const id = e.getAttribute('aria-describedby'); const targets = Array.from(document.querySelectorAll('[id]')).filter(e => e.id === id);
if (targets.length !== 1) throw new Error('Missing or duplicate description: ' + id);
return targets[0].textContent.replace(/\\s+/g, ' ').trim(); })()`)
assert(expected.length > 0)
const { root } = await win.webContents.debugger.sendCommand('DOM.getDocument')
const { nodeId } = await win.webContents.debugger.sendCommand('DOM.querySelector', { nodeId: root.nodeId, selector })
const { nodes } = await win.webContents.debugger.sendCommand('Accessibility.getPartialAXTree', { nodeId })
assert.equal(nodes[0].description?.value, expected, 'Chromium accessibility description: ' + selector)
}
const checkHelp = async (selector, root, describedControl) => {
await reveal(selector)
if (describedControl) await description(describedControl)
const before = await snapshot(root)
const geometry = await js(`(() => { const button = document.querySelector(${JSON.stringify(selector)});
const previous = button.previousSibling; const range = document.createRange(); range.selectNodeContents(previous);
const label = range.getBoundingClientRect(), r = button.getBoundingClientRect();
const style = getComputedStyle(button), svg = button.querySelector('svg'), icon = getComputedStyle(svg);
return { label: button.getAttribute('aria-label'), sameLine: Math.min(label.bottom, r.bottom) > Math.max(label.top, r.top),
appearance: Object.fromEntries(['width', 'height', 'padding', 'margin', 'color', 'backgroundColor', 'border', 'borderRadius'].map(key => [key, style[key]])),
icon: { width: icon.width, height: icon.height, color: icon.color },
gap: r.left - label.right, centerOffset: r.top + r.height / 2 - label.top - label.height / 2,
afterLabel: r.left >= label.right - 1, nested: !!button.closest('label, summary, [role=tab]') || !!button.parentElement.closest('button') }; })()`)
assert(geometry.sameLine && geometry.afterLabel, 'Help not beside label: ' + JSON.stringify(geometry))
assert(!geometry.nested, 'Help nested in another control: ' + geometry.label)
const focused = () => js('document.activeElement.tagName + ":" + document.activeElement.id + ":" + document.activeElement.getAttribute("aria-label")')
const beforeHover = await focused()
await mouse(selector)
await wait('!!document.querySelector("[role=tooltip]")')
assert.equal(await focused(), beforeHover, 'Consumer hover stole focus')
await key('Escape')
await wait('!document.querySelector("[role=tooltip]")')
await js(`document.querySelector(${JSON.stringify(selector)}).focus()`)
await wait('!!document.querySelector("[role=tooltip]")')
await key('Escape')
await wait('!document.querySelector("[role=tooltip]")')
await mouse(selector, true)
await wait('!!document.querySelector("[role=tooltip]")')
assert(await js(`document.activeElement === document.querySelector(${JSON.stringify(selector)})`), 'Help click focused another input')
const panel = await js(`(() => { const e = document.querySelector('[role=tooltip]'), r = e.getBoundingClientRect();
const style = getComputedStyle(e);
const hit = document.elementFromPoint(r.x + r.width/2, r.y + Math.min(r.height/2, 20));
return { text: e.textContent, width: r.width, height: r.height, scrollHeight: e.scrollHeight, clientHeight: e.clientHeight,
appearance: Object.fromEntries(['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'color', 'backgroundColor', 'padding', 'border', 'borderRadius'].map(key => [key, style[key]])),
inBounds: r.left >= 15 && r.top >= 15 && r.right <= innerWidth - 15 && r.bottom <= innerHeight - 15,
wraps: e.scrollWidth <= e.clientWidth, hit: e.contains(hit), modal: !!e.closest('[aria-modal=true]') }; })()`)
assert(panel.text.trim().length > 0 && panel.inBounds && panel.wraps && panel.hit, 'Unreadable help: ' + JSON.stringify(panel))
if (root.includes('dialog') || root.includes('settings-panel') || root.includes('application-center')) assert(panel.modal)
if (describedControl) await description(describedControl)
assert.equal(await snapshot(root), before, 'Help click changed values, tabs or disclosure state')
if (artifacts) await writeFile(join(artifacts, `${capturePrefix}-${++captureIndex}.png`), (await win.webContents.capturePage()).toPNG())
await key('Escape')
assert(await js(`!document.querySelector('[role=tooltip]') && !!document.querySelector(${JSON.stringify(root)})`), 'Escape closed consumer')
assert.equal(await snapshot(root), before, 'Help dismissal changed consumer state')
if (describedControl) await description(describedControl)
win.webContents.sendInputEvent({ type: 'mouseMove', x: 5, y: 5 })
await settle()
return { ...geometry, panelAppearance: panel.appearance, characters: panel.text.length, width: panel.width, height: panel.height }
}
const profileId = '00000000-0000-4000-8000-000000000001'
for (const theme of ['light', 'dark']) {
await js(`document.documentElement.dataset.theme = '${theme}'`)
for (const [width, height] of [[1280, 800], [640, 480]]) {
capturePrefix = `${theme}-${width}x${height}`
captureIndex = 0
win.setContentSize(width, height)
await settle()
const checked = []
await mouse('#applications', true)
await wait('!!document.querySelector("#application-center")')
await mouse('[aria-label="魔法笔记 应用设置"]', true)
await wait('!!document.querySelector("#magic-note-canvas-page-count")')
assert.equal(await js('document.querySelector("#magic-note-canvas-page-count").value'), '3')
checked.push(await checkHelp('.inline-help[aria-label="常驻左侧菜单"]', '#application-center', '[aria-describedby="application-pin-help"][role=switch]'))
checked.push(await checkHelp('.inline-help[aria-label="发送画布页数"]', '#application-center', '#magic-note-canvas-page-count'))
await key('Escape')
await wait('!document.querySelector("#application-center")')
await mouse('#settings', true)
const context = '#model-context-window-' + profileId
await wait(`!!document.querySelector('${context}')`)
assert.equal(await js(`document.querySelector('${context}').value`), '128')
checked.push(await checkHelp('.inline-help[aria-label="上下文上限(可选)"]', '.settings-panel', context))
checked.push(await checkHelp('.inline-help[aria-label="自定义请求体"]', '.settings-panel', '#model-request-body-' + profileId))
for (const applicationHelp of checked.slice(0, 2)) {
assert.deepEqual(applicationHelp.appearance, checked[2].appearance, 'Application/settings help button styles differ')
assert.deepEqual(applicationHelp.icon, checked[2].icon, 'Application/settings help icons differ')
assert.deepEqual(applicationHelp.panelAppearance, checked[2].panelAppearance, 'Application/settings tooltip styles differ')
assert.equal(applicationHelp.gap, checked[2].gap, 'Application/settings label gaps differ')
assert.equal(applicationHelp.centerOffset, checked[2].centerOffset, 'Application/settings label alignment differs')
}
await key('Escape')
await wait('!document.querySelector(".settings-panel")')
await mouse('#retrieval', true)
await wait('!!document.querySelector(".knowledge-retrieval-workbench")')
const queryHelp = '.knowledge-workbench-section__heading .inline-help'
checked.push(await checkHelp(queryHelp, '.knowledge-retrieval-workbench'))
const disclosureHeight = await js('document.querySelector(".knowledge-workbench-settings").getBoundingClientRect().height')
await js('document.querySelector(".knowledge-workbench-settings > summary").focus({preventScroll: true})')
await key('Space')
assert(await js('document.querySelector(".knowledge-workbench-settings").open'))
checked.push(await checkHelp(queryHelp, '.knowledge-retrieval-workbench'))
assert.equal(await js('document.querySelector(".knowledge-retrieval-workbench textarea").value'), 'Preserve this query')
await key('Escape')
await wait('!document.querySelector(".knowledge-retrieval-workbench")')
await mouse('#activity', true)
await wait('!!document.querySelector(".page-header .inline-help")')
checked.push(await checkHelp('.page-header .inline-help', 'main'))
await mouse('#home', true)
evidence.push({ theme, viewport: `${width}x${height}`, retrievalDisclosureHeight: disclosureHeight, checked })
}
}
assert.equal(await js('document.documentElement.dataset.fixtureActions ?? "[]"'), '[]', 'Help triggered a consumer operation')
console.log('InlineHelp consumer evidence: ' + JSON.stringify(evidence))
console.log('InlineHelp consumer actions: 0; model calls: 0; Chromium AX descriptions passed closed/open/closed')
if (artifacts) await writeFile(join(artifacts, 'evidence.json'), JSON.stringify({ evidence, consumerActions: 0, modelCalls: 0 }, null, 2))
win.destroy()
app.exit(0)
}).catch(error => { console.error(error); app.exit(1) })