Files
goodbuddy/tests/support/mermaid-viewer-driver.mjs
mesalogo 785b1ee468 fix: keep Mermaid diagram edges reachable and add PNG export
Fix oversized diagram alignment in the large-diagram viewer and provide complete PNG downloads independent of zoom and pan. Cover the full window and shared native drag handling, with regression checks for both themes and narrow windows.

Validation: 58 focused tests passed, including 9 Electron scenarios and 10 PNG downloads; typecheck and lint passed. Full-suite runs did not complete.

Release note: Mermaid 大图放大后可浏览全部边缘,并可从弹窗导出完整 PNG 图片。
2026-09-23 11:53:09 +08:00

187 lines
10 KiB
JavaScript

import { app, BrowserWindow, nativeImage } from 'electron'
import assert from 'node:assert/strict'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { URLSearchParams } from 'node:url'
import process from 'node:process'
import console from 'node:console'
import { setTimeout } from 'node:timers/promises'
const directory = process.env.GOODBUDDY_MERMAID_DIRECTORY
const artifacts = process.env.GOODBUDDY_MERMAID_ARTIFACTS || directory
app.setPath('userData', join(directory, 'profile'))
app.commandLine.appendSwitch('force-device-scale-factor', '1')
app.whenReady().then(async () => {
await mkdir(artifacts, { recursive: true })
const win = new BrowserWindow({
show: false, width: 1440, height: 1000, useContentSize: true,
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false }
})
const errors = []
win.webContents.on('console-message', event => {
if (event.level === 'error') errors.push(event.message)
})
const js = code => win.webContents.executeJavaScript(code)
const wait = async code => {
for (let attempt = 0; attempt < 600; attempt++) {
if (await js(code)) return
await setTimeout(25)
}
throw new Error(`Timeout: ${code}\n${errors.join('\n')}`)
}
// Hidden Chromium windows can throttle animation frames even with backgroundThrottling disabled.
const settle = () => setTimeout(25)
const click = async selector => {
const point = await js(`(() => {
const e = document.querySelector(${JSON.stringify(selector)});
if (!e || e.disabled) throw new Error('Missing/disabled control: ' + ${JSON.stringify(selector)});
const r = e.getBoundingClientRect(), x = r.x + r.width / 2, y = r.y + r.height / 2;
if (!e.contains(document.elementFromPoint(x, y))) throw new Error('Control not mouse reachable: ' + ${JSON.stringify(selector)});
return { x: Math.round(x), y: Math.round(y) };
})()`)
win.webContents.sendInputEvent({ type: 'mouseDown', ...point, button: 'left', clickCount: 1 })
win.webContents.sendInputEvent({ type: 'mouseUp', ...point, button: 'left', clickCount: 1 })
await settle()
}
const download = async (name, label, dimensions) => {
assert(await js(`!!document.querySelector('.mermaid-viewer button[aria-label="${label}"]')`), `Missing export button: ${label}`)
const path = join(artifacts, `${name}.png`)
let listener
let timer
const completed = new Promise((resolve, reject) => {
timer = globalThis.setTimeout(() => reject(new Error(`No completed PNG download: ${name}`)), 15_000)
listener = (_event, item) => {
try {
assert.match(item.getFilename(), /\.png$/iu)
assert.equal(item.getMimeType(), 'image/png')
item.setSavePath(path)
item.once('done', (_event, state) => {
if (state === 'completed') resolve()
else reject(new Error(`Download ${state}: ${name}`))
})
} catch (error) { reject(error) }
}
win.webContents.session.once('will-download', listener)
})
try {
await Promise.all([completed, click(`.mermaid-viewer button[aria-label="${label}"]`)])
} finally {
globalThis.clearTimeout(timer)
win.webContents.session.removeListener('will-download', listener)
}
const bytes = await readFile(path)
assert.equal(bytes.subarray(0, 8).toString('hex'), '89504e470d0a1a0a')
const image = nativeImage.createFromBuffer(bytes)
const { width, height } = image.getSize()
const scale = Math.min(2, 4096 / (dimensions.width + 32), 4096 / (dimensions.height + 32))
assert(Math.abs(width - (dimensions.width + 32) * scale) <= 1, `PNG width ${width}: ${JSON.stringify(dimensions)}`)
assert(Math.abs(height - (dimensions.height + 32) * scale) <= 1, `PNG height ${height}: ${JSON.stringify(dimensions)}`)
assert(width <= 4096 && height <= 4096)
// NativeImage decodes the actual saved PNG to BGRA, independent of the export canvas.
const bitmap = image.toBitmap()
const colors = { red: 0, green: 0, blue: 0 }
const bounds = { left: width, top: height, right: 0, bottom: 0 }
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const offset = (y * width + x) * 4
const [b, g, r, a] = bitmap.subarray(offset, offset + 4)
const color = a > 240 && (r > 220 && g < 35 && b < 35 ? 'red'
: g > 220 && r < 35 && b < 35 ? 'green'
: b > 220 && r < 35 && g < 35 ? 'blue' : undefined)
if (color) {
colors[color]++
bounds.left = Math.min(bounds.left, x)
bounds.top = Math.min(bounds.top, y)
bounds.right = Math.max(bounds.right, x)
bounds.bottom = Math.max(bounds.bottom, y)
}
}
}
assert(Object.values(colors).every(count => count > 30), `Missing start/middle/end content: ${JSON.stringify(colors)}`)
const padding = Math.floor(16 * scale) - 1
assert(bounds.left >= padding && bounds.top >= padding && bounds.right < width - padding && bounds.bottom < height - padding,
`Missing PNG padding: ${JSON.stringify({ bounds, padding, width, height })}`)
return { width, height, colors, bitmap }
}
const reports = []
const failures = []
const scenarios = ['light', 'dark'].flatMap(theme => [1440, 390].flatMap(width =>
['wide', 'tall'].map(shape => ({ theme, width, shape, locale: width === 390 ? 'zh-CN' : 'en-US' }))))
scenarios.push({ theme: 'light', width: 1440, shape: 'small', locale: 'en-US' })
for (const scenario of scenarios) {
const { theme, width, shape, locale } = scenario
const name = `${theme}-${width}-${shape}`
win.setContentSize(width, width === 390 ? 740 : 1000)
await win.loadURL(`${process.env.GOODBUDDY_MERMAID_URL}?${new URLSearchParams({ theme, shape, locale })}`)
await wait('!!document.querySelector(".mermaid-diagram[aria-busy=false] .mermaid-diagram__viewport > svg")')
await js('document.fonts.ready')
await click('.mermaid-diagram__actions button:last-child')
await wait('!!document.querySelector(".mermaid-viewer__diagram > svg")')
const dimensions = await js(`(() => {
const svg = document.querySelector('.mermaid-viewer__diagram > svg');
return { width: Number(svg.getAttribute('width')), height: Number(svg.getAttribute('height')), nodes: svg.querySelectorAll('.node').length,
themeFill: getComputedStyle(svg.querySelector('.node[id*="-N1-"] rect')).fill };
})()`)
assert.equal(dimensions.nodes, shape === 'small' ? 3 : 24, 'Must render the real Mermaid graph')
const label = locale === 'en-US' ? 'Export PNG' : '\u5bfc\u51fa PNG'
let before
if (shape === 'small') {
try { before = await download(`${name}-100`, label, dimensions) }
catch (error) { failures.push(`${name}-100: ${error.message}`) }
}
for (let step = 0; step < 8; step++) {
await click('.mermaid-viewer button:has(.lucide-zoom-in)')
await wait(`document.querySelector('.mermaid-viewer__zoom').textContent === '${125 + step * 25}%'`)
}
assert.equal(await js('document.querySelector(".mermaid-viewer__zoom").textContent'), '300%')
const corners = []
for (const [horizontal, vertical] of [[0, 0], [1, 0], [0, 1], [1, 1]]) {
await js(`(() => { const e = document.querySelector('.mermaid-viewer__canvas'); e.scrollLeft = ${horizontal} * e.scrollWidth; e.scrollTop = ${vertical} * e.scrollHeight; })()`)
await settle()
const corner = await js(`(() => {
const e = document.querySelector('.mermaid-viewer__canvas'), c = e.getBoundingClientRect();
const s = document.querySelector('.mermaid-viewer__diagram > svg').getBoundingClientRect();
const m = document.querySelector('.mermaid-viewer').getBoundingClientRect();
const b = document.querySelector('.mermaid-viewer-backdrop').getBoundingClientRect();
return { left: s.left - c.left - e.clientLeft, top: s.top - c.top - e.clientTop,
right: c.left + e.clientLeft + e.clientWidth - s.right, bottom: c.top + e.clientTop + e.clientHeight - s.bottom,
scrollX: e.scrollLeft, scrollY: e.scrollTop, overflowX: e.scrollWidth - e.clientWidth, overflowY: e.scrollHeight - e.clientHeight,
modalFits: m.left >= 0 && m.right <= innerWidth && m.top >= 0 && m.bottom <= innerHeight,
backdropFits: b.top === 0 && b.left === 0 && b.right === innerWidth && b.bottom === innerHeight };
})()`)
if (!corner.modalFits || !corner.backdropFits) failures.push(`${name}: modal/backdrop outside viewport ${JSON.stringify(corner)}`)
if (corner[horizontal ? 'right' : 'left'] < -1) failures.push(`${name}: unreachable horizontal edge ${JSON.stringify(corner)}`)
if (corner[vertical ? 'bottom' : 'top'] < -1) failures.push(`${name}: unreachable vertical edge ${JSON.stringify(corner)}`)
if (shape !== 'small') assert(corner[shape === 'wide' ? 'overflowX' : 'overflowY'] > 1000, 'Fixture must overflow substantially at 300%')
corners.push(corner)
await writeFile(join(artifacts, `${name}-corner-${horizontal}-${vertical}.png`), (await win.webContents.capturePage()).toPNG())
}
let png
try {
const result = await download(`${name}-300`, label, dimensions)
if (before) assert(result.bitmap.equals(before.bitmap), 'PNG content must not depend on modal zoom or scroll position')
png = { width: result.width, height: result.height, colors: result.colors }
} catch (error) {
failures.push(`${name}-300: ${error.message}`)
console.log(`Mermaid ${name}: ${error.message}`)
}
reports.push({ ...scenario, dimensions, corners, png })
await writeFile(join(artifacts, 'mermaid-measurements.json'), JSON.stringify({ reports, failures }, null, 2))
console.log(`Mermaid ${name}: measured four corners at 300%; ${png ? `downloaded ${png.width}x${png.height} PNG with start/middle/end content` : 'PNG FAILED'}`)
}
assert.deepEqual(errors, [], 'Renderer console errors')
for (const shape of ['wide', 'tall']) {
const fills = reports.filter(report => report.width === 1440 && report.shape === shape).map(report => report.dimensions.themeFill)
assert.notEqual(fills[0], fills[1], 'Real Mermaid rendering must follow the selected theme')
}
await writeFile(join(artifacts, 'mermaid-measurements.json'), JSON.stringify({ reports, failures }, null, 2))
assert.deepEqual(failures, [], 'Mermaid modal/download regression')
console.log(`Mermaid PASS: ${reports.length} scenarios; 10 real PNG downloads; artifacts ${artifacts}`)
win.destroy()
app.quit()
}).catch(error => {
console.error(error)
app.exit(1)
})