// @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', '
')) }) } }], 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.equal(menu.width, 220); 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.equal(previewMenu.width, 220); 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 === "移除来源"')); for (let i = 0; i < 2; i++) { win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Up' }); win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Up' }); await settle(); } assert(await js('document.activeElement.textContent === "重新解析"')); 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)