Files
goodbuddy/tests/magic-notes-footer.electron.test.ts
T
mesalogo 393a07ccdf fix: keep note capture actions compact and right-aligned
Remove initial generic validation instructions, show size errors beside their fields, and expose capture scope through contextual help. Keep save errors beside actions where space allows and preserve content-sized Save buttons in narrow note workspaces.

Release note: Note capture now uses a compact action bar with right-aligned buttons, on-demand scope help, and field-specific validation. Narrow workspaces no longer stretch the Save button.
2026-09-28 21:30:03 +08:00

187 lines
13 KiB
TypeScript

// @vitest-environment node
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { mkdtemp, readFile, 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 compact capture footers and responsive save errors in Electron across locales and themes', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-notes-footer-'))
const server = await createServer({
configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
plugins: [react(), { name: 'notes-footer', configureServer(server) {
server.middlewares.use('/footer.html', async (_request, response) => {
response.setHeader('Content-Type', 'text/html')
response.end(await server.transformIndexHtml('/footer.html',
'<html><body><script type="module" src="/tests/support/magic-notes-footer-regression.tsx"></script></body></html>'))
})
} }],
server: { host: '127.0.0.1', port: 0, watch: null },
optimizeDeps: { entries: ['tests/support/magic-notes-footer-regression.tsx'] }
})
try {
await server.listen()
const driver = join(directory, 'driver.cjs')
await writeFile(driver, `
const { app, BrowserWindow } = require('electron');
const fs = require('node:fs');
const assert = require('node:assert/strict');
const trace = message => fs.appendFileSync(${JSON.stringify(join(directory, 'driver.log'))}, message + '\\n');
trace('Starting Electron');
app.commandLine.appendSwitch('force-device-scale-factor', '1');
// Keep animation-frame measurements running when other test windows cover this one.
app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion');
app.setPath('userData', ${JSON.stringify(join(directory, 'profile'))});
app.whenReady().then(async () => {
trace('Electron ready');
const win = new BrowserWindow({ show: true, width: 900, height: 720, useContentSize: true,
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } });
const js = async code => {
let timeout;
try {
return await Promise.race([win.webContents.executeJavaScript(code), new Promise((_, reject) => {
timeout = setTimeout(() => reject(new Error('Renderer timeout: ' + code)), 15000);
})]);
} catch (error) { throw new Error(code + ': ' + error.message); }
finally { clearTimeout(timeout); }
};
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 selector => {
const r = await js('document.querySelector(' + JSON.stringify(selector) + ').getBoundingClientRect().toJSON()');
win.focus(); win.webContents.focus();
const x = Math.round(r.x + r.width / 2), y = Math.round(r.y + r.height / 2);
win.webContents.sendInputEvent({ type: 'mouseMove', x, y });
win.webContents.sendInputEvent({ type: 'mouseDown', x, y, button: 'left', clickCount: 1 });
win.webContents.sendInputEvent({ type: 'mouseUp', x, y, button: 'left', clickCount: 1 });
await settle();
};
const resize = async width => {
win.show(); win.focus();
win.setContentSize(width, 720);
await wait('innerWidth === ' + width); await settle();
};
const measure = () => js(
'(() => {' +
'const footer = document.querySelector(".magic-note-panel__footer"), style = getComputedStyle(footer);' +
'const rect = selector => document.querySelector(selector)?.getBoundingClientRect().toJSON();' +
'return { footer: footer.getBoundingClientRect().toJSON(), actions: rect(".magic-note-panel__footer .magic-note-panel__actions"),' +
'cancel: rect(".magic-note-panel__footer .secondary-button"), save: rect(".magic-note-panel__footer .primary-button"),' +
'error: rect("#compact-note-save-error"), paddingRight: parseFloat(style.paddingRight),' +
'chrome: parseFloat(style.paddingTop) + parseFloat(style.paddingBottom) + parseFloat(style.borderTopWidth),' +
'background: style.backgroundColor, overflow: document.documentElement.scrollWidth > innerWidth }; })()');
const aligned = (m, context) => {
assert(Math.abs(m.save.right - (m.footer.right - m.paddingRight)) <= 1, context + ': right alignment ' + JSON.stringify(m));
assert(Math.abs(m.cancel.y - m.save.y) <= 1, context + ': buttons stay on one row');
assert(m.cancel.right <= m.save.x && m.cancel.x >= m.footer.x, context + ': buttons fit without overlap');
assert(m.save.bottom <= m.footer.bottom && m.footer.bottom <= 720, context + ': footer remains visible');
assert(!m.overflow, context + ': no horizontal overflow');
};
const screenshot = async name => {
if (process.env.GOODBUDDY_NOTES_SCREENSHOT_DIR) {
fs.writeFileSync(require('node:path').join(process.env.GOODBUDDY_NOTES_SCREENSHOT_DIR, name + '.png'), (await win.webContents.capturePage()).toPNG());
}
};
const results = [];
for (const locale of ['en-US', 'zh-CN']) for (const theme of ['light', 'dark']) for (const kind of ['message', 'conversation']) {
const context = locale + '/' + theme + '/' + kind;
trace(context + ': navigating');
await win.loadURL(${JSON.stringify(server.resolvedUrls!.local[0] + 'footer.html')} + '?locale=' + locale + '&theme=' + theme + '&kind=' + kind);
trace(context + ': loaded');
await resize(900);
trace(context + ': resized');
await wait('!!document.querySelector("#compact-note-text") && !document.querySelector(".magic-note-panel__scroll [role=status]")');
trace(context + ': rendered');
await js('document.fonts.ready'); await settle();
trace(context + ': fonts ready');
assert.equal(await js('document.querySelector("#compact-note-text").value'), 'Captured text', context);
assert.equal(await js('document.body.dataset.saveCount'), '0', context);
assert(await js('document.querySelector(".magic-note-panel__footer .primary-button").disabled'), context);
assert.equal(await js('document.querySelector(".magic-note-panel__footer p")'), null, context + ': no initial footer paragraph');
const initial = [];
for (const width of [900, 300]) {
trace(context + ': initial ' + width);
await resize(width);
const m = await measure(); aligned(m, context + '/' + width);
assert.equal(m.footer.width, width, context + ': actual panel width');
assert(Math.abs(m.footer.height - m.chrome - m.actions.height) <= 1, context + ': compact footer ' + JSON.stringify(m));
assert(m.footer.height <= 65, context + ': bounded initial footer height');
assert.equal(await js('document.body.innerText.includes(document.body.dataset.scope)'), false, context + ': scope hidden');
await click('.magic-note-panel__source-heading .inline-help');
await wait('!!document.querySelector("[role=tooltip]")');
assert(await js('document.querySelector("[role=tooltip]").checkVisibility()'), context);
assert.equal(await js('document.querySelector("[role=tooltip]").textContent'), await js('document.body.dataset.scope'), context + ': correct capture scope');
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' });
await wait('!document.querySelector("[role=tooltip]")');
assert.equal(await js('document.body.innerText.includes(document.body.dataset.scope)'), false, context);
assert.equal((await measure()).footer.height, m.footer.height, context + ': help does not expand footer');
initial.push({ width, height: m.footer.height, saveWidth: m.save.width });
await screenshot('magic-notes-footer-' + locale + '-' + theme + '-' + kind + '-initial-' + width);
}
assert(Math.abs(initial[0].saveWidth - initial[1].saveWidth) <= 1, context + ': content-sized button');
await resize(900);
trace(context + ': saving');
await click('.magic-note-panel__scroll .segmented-control button:last-child');
await wait('!!document.querySelector("#compact-note-title")');
await click('.magic-note-panel__footer .primary-button');
await wait('!!document.querySelector("#compact-note-save-error") && !document.querySelector(".magic-note-panel__footer .primary-button").disabled');
assert.equal(await js('document.body.dataset.saveCount'), '1', context + ': real save handler reaches storage');
assert.equal(await js('document.querySelector("#compact-note-save-error").textContent'), 'Storage unavailable. Please try again.', context);
assert.equal(await js('document.querySelector("#compact-note-save-error").getAttribute("role")'), 'alert', context);
assert.equal(await js('document.querySelector("#compact-note-text").value'), 'Captured text', context + ': failed save preserves draft');
const wide = await measure(); aligned(wide, context + '/wide-error');
assert(Math.abs((wide.error.y + wide.error.height / 2) - (wide.actions.y + wide.actions.height / 2)) <= 1, context + ': error and actions share row');
assert(wide.error.right <= wide.actions.x, context + ': error does not overlap actions');
assert.equal(wide.footer.height, initial[0].height, context + ': wide error adds no footer height');
await screenshot('magic-notes-footer-' + locale + '-' + theme + '-' + kind + '-error-900');
await resize(300);
const narrow = await measure(); aligned(narrow, context + '/narrow-error');
trace(context + ': narrow error measured');
assert(narrow.actions.y >= narrow.error.bottom + 7, context + ': error wraps above buttons ' + JSON.stringify(narrow));
assert(narrow.footer.height > wide.footer.height, context + ': wrapped footer grows');
assert(Math.abs(narrow.save.width - wide.save.width) <= 1, context + ': error does not stretch button');
assert(await js('(() => { const e = document.querySelector("#compact-note-save-error"); return e.checkVisibility() && e.scrollWidth <= e.clientWidth && e.scrollHeight <= e.clientHeight; })()'), context + ': error is not clipped');
await screenshot('magic-notes-footer-' + locale + '-' + theme + '-' + kind + '-error-300');
results.push({ locale, theme, kind, initial, wideHeight: wide.footer.height, narrowHeight: narrow.footer.height,
wideErrorY: wide.error.y, wideActionsY: wide.actions.y, narrowErrorBottom: narrow.error.bottom,
narrowActionsY: narrow.actions.y, rightInset: narrow.footer.right - narrow.save.right, background: wide.background });
}
assert.notEqual(results[0].background, results[2].background, 'Real theme styles applied');
fs.writeFileSync(${JSON.stringify(join(directory, 'result.json'))}, JSON.stringify(results));
app.exit(0);
}).catch(error => { trace(error.stack || String(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 })
let timedOut = false
const timeout = setTimeout(() => { timedOut = true; child.kill() }, 90000)
try {
const { code, signal } = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
child.once('close', (code, signal) => resolve({ code, signal }))
child.once('error', reject)
})
output += await readFile(join(directory, 'driver.log'), 'utf8').catch(() => '')
expect(timedOut, `Electron footer exceeded 90s (exit=${code}, signal=${signal}).\n${output}`).toBe(false)
expect(signal, output).toBeNull()
expect(code, output).toBe(0)
} finally { clearTimeout(timeout) }
const results = JSON.parse(await readFile(join(directory, 'result.json'), 'utf8'))
console.info('Notes footer Electron geometry:', JSON.stringify(results, null, 2))
expect(results).toHaveLength(8)
} finally {
await server.close()
await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
}
}, 120000)