Files
goodbuddy/tests/knowledge-layout.electron.test.ts
T
mesalogo ceb275bdb8 fix: clarify knowledge document actions and task history
Restore URL identity in merged rows and replace crowded document actions with a primary entry and accessible menu. Organize preview navigation and maintenance actions, and show task history as batches with matching counts and filters.

Keep preview task scope consistent for multi-document sources and track pending operations independently so one source does not block another source's pause action. Include responsive Electron layout and behavioral regressions, bilingual copy, and design documentation.

Validation: focused regressions, typecheck, lint, and diff checks passed. Electron fixtures cover light/dark and wide/narrow layouts. Full tests reported two previously known HeartbeatCenter assertions; real import and parsing integration was not rerun.

Release note: 知识库文档列表保留来源网址,统一查看入口和更多操作,并整理解析结果页。任务历史按批次展示,筛选计数与当前对象一致,不同来源的操作互不阻塞。
2026-09-27 12:55:50 +08:00

120 lines
8.6 KiB
TypeScript

// @vitest-environment node
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { createServer } from 'vite'
import react from '@vitejs/plugin-react'
import { expect, it } from 'vitest'
it('renders knowledge rows, menus, preview and task history in wide and narrow Electron windows', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-knowledge-layout-'))
const server = await createServer({ configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
plugins: [react(), { name: 'knowledge-layout', configureServer(server) {
server.middlewares.use('/knowledge.html', async (_request, response) => {
response.setHeader('Content-Type', 'text/html')
response.end(await server.transformIndexHtml('/knowledge.html',
'<html><body><div id="root"></div><script type="module" src="/tests/support/knowledge-layout-regression.tsx"></script></body></html>'))
})
} }], server: { host: '127.0.0.1', port: 0, watch: null } })
try {
await server.listen()
const driver = join(directory, 'driver.cjs')
await writeFile(driver, `
const { app, BrowserWindow } = require('electron');
const assert = require('node:assert/strict');
const { writeFile } = require('node:fs/promises');
const { join } = require('node:path');
app.commandLine.appendSwitch('force-device-scale-factor', '1');
app.setPath('userData', ${JSON.stringify(join(directory, 'profile'))});
app.whenReady().then(async () => {
const win = new BrowserWindow({ show: true, width: 1100, height: 720, useContentSize: true,
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } });
const js = code => win.webContents.executeJavaScript(code);
const settle = () => js('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
const wait = async code => { for (let i = 0; i < 400; i++) {
if (await js(code)) return; await new Promise(resolve => setTimeout(resolve, 25));
} throw new Error('Timeout: ' + code); };
const click = async text => { await js('Array.from(document.querySelectorAll("button")).find(b => b.textContent === ' + JSON.stringify(text) + ').click()'); await settle(); };
const capture = async name => { if (process.env.GOODBUDDY_LAYOUT_ARTIFACTS) await writeFile(join(process.env.GOODBUDDY_LAYOUT_ARTIFACTS, name + '.png'), (await win.webContents.capturePage()).toPNG()); };
console.log('Loading knowledge fixture');
await win.loadURL(${JSON.stringify(server.resolvedUrls!.local[0] + 'knowledge.html')});
await wait('document.querySelectorAll("tbody tr").length === 2');
console.log('Knowledge fixture ready');
for (const theme of ['light', 'dark']) {
await js('document.documentElement.dataset.theme = ' + JSON.stringify(theme));
for (const width of [1100, 720, 420]) {
win.setContentSize(width, width === 420 ? 480 : 720); await settle();
console.log('Checking ' + theme + ' ' + width);
assert(await js('document.documentElement.scrollWidth <= innerWidth'));
assert(await js('document.querySelector(".workspace-panel-scroll").scrollWidth <= document.querySelector(".workspace-panel-scroll").clientWidth'));
assert(await js('Array.from(document.querySelectorAll("tbody tr")).every(r => r.querySelectorAll("button").length === 2)'));
assert(await js('Array.from(document.querySelectorAll(".knowledge-document-path")).map(e => e.textContent).join(" ").includes("edition=2")'));
await capture('knowledge-list-' + theme + '-' + width);
console.log('Opening list menu');
const height = await js('document.querySelector("tbody tr").getBoundingClientRect().height');
await js('document.querySelector("tbody [aria-haspopup=menu]").scrollIntoView({block:"center",inline:"end"}); document.querySelector("tbody [aria-haspopup=menu]").click()');
await wait('!!document.querySelector("[role=menuitem]")'); await settle();
assert.equal(await js('document.querySelector("tbody tr").getBoundingClientRect().height'), height);
const menu = await js('document.querySelector("[role=menu]").getBoundingClientRect().toJSON()');
assert(menu.left >= 15 && menu.right <= width - 15 && menu.top >= 15 && menu.height >= 32, JSON.stringify(menu));
await capture('knowledge-menu-' + theme + '-' + width);
console.log('Closing list menu');
win.focus(); win.webContents.focus();
await wait('document.hasFocus()');
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' }); await settle();
assert(await js('!document.querySelector("[role=menu]") && document.activeElement.matches("[aria-haspopup=menu]")'));
await click('查看解析结果');
console.log('Opening preview');
await wait('!!document.querySelector(".document-result-preview")');
assert(await js('document.activeElement.textContent === "返回文档列表"'));
assert(await js('document.documentElement.scrollWidth <= innerWidth'));
assert(await js('document.querySelector(".workspace-panel-scroll").scrollWidth <= document.querySelector(".workspace-panel-scroll").clientWidth'));
assert(await js('!Array.from(document.querySelectorAll("button")).some(b => b.textContent === "添加所选图片")'));
await capture('knowledge-preview-' + theme + '-' + width);
console.log('Opening preview menu');
await js('document.querySelector(".knowledge-document-preview__header [aria-haspopup=menu]").click()'); await settle();
const previewMenu = await js('document.querySelector("[role=menu]").getBoundingClientRect().toJSON()');
assert(previewMenu.height >= 100 && previewMenu.top >= 16 && previewMenu.bottom <= (width === 420 ? 480 : 720) - 15, JSON.stringify(previewMenu));
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'End' });
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'End' }); await settle();
assert(await js('document.activeElement.textContent.includes("移除来源")'));
await capture('knowledge-preview-menu-' + theme + '-' + width);
console.log('Closing preview menu');
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' }); await settle();
await click('返回文档列表');
console.log('Returned to list');
assert(await js('document.activeElement.textContent === "查看解析结果"'));
await js('document.querySelector("tbody [aria-haspopup=menu]").click()'); await settle();
console.log('Opened task navigation menu');
await click('查看任务');
console.log('Navigated to tasks');
await click('历史');
console.log('Opening history');
assert(await js('document.querySelectorAll(".knowledge-task-list > li").length === 1 && !document.querySelector("progress") && !document.querySelector(".knowledge-task__children")'));
assert(await js('document.documentElement.scrollWidth <= innerWidth'));
await capture('knowledge-history-' + theme + '-' + width);
await click('Documents');
}
}
console.log('Knowledge layout passed: light/dark, 1100/720/420px, stable row height, bounded menus, native Escape, preview focus, compact history');
app.exit(0);
}).catch(error => { console.error(error); app.exit(1); });
`)
const env = { ...process.env }
delete env.ELECTRON_RUN_AS_NODE
const child = spawn(createRequire(import.meta.url)('electron'), [driver], { env, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', data => { output += data })
child.stderr.on('data', data => { output += data })
const timeout = setTimeout(() => child.kill(), 60000)
try {
const code = await new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) })
expect(code, output).toBe(0)
} finally { clearTimeout(timeout) }
} finally { await server.close(); await rm(directory, { recursive: true, force: true }) }
}, 90000)