Files
moho/scripts/ui-check.mjs

158 lines
5.9 KiB
JavaScript

#!/usr/bin/env node
/**
* Layout checks against the real app: nothing that opens over the UI may
* extend outside the window.
*
* Unit tests cannot see this. A DOM without a layout engine measures every
* element as 0x0 at 0,0, so a popup hanging off the bottom of the screen
* passes there - which is how one shipped (#211: the emoji picker opened 134
* pixels below the screen, because it was placed once against its first,
* short measurement and then grew). So this launches the built app with a
* debugging port, gives it something to open popups over, and measures.
*
* Measured several times after each popup opens, not once: the bug above was
* precisely that the first measurement was not the last one.
*
* Self-contained. The app runs against a throwaway home directory, so no real
* account is touched, and the conversation it opens popups over is served by
* a small fake IRC server started here: a channel, some people in it, and a
* few things said.
*
* npm run test:ui (builds first; needs the daemon built too)
*
* On a machine with no display, run it under xvfb-run.
*/
import { click, fakeIrc, launchApp, openIrcChannel, sleep } from './lib/harness.mjs'
/** When each popup is measured after it is opened, in milliseconds. */
const SAMPLES = [80, 400, 1500, 3500]
/** Window sizes to check at: an ordinary window, and a cramped one. */
const SIZES = [
{ width: 1280, height: 800 },
{ width: 760, height: 560 }
]
/** Everything that opens on top of the UI. */
const POPUPS = '.context-menu, .emoji-picker, .header-popover, .profile-card, [role="dialog"], [role="menu"]'
const PEOPLE = ['alice', 'bob', 'carol', 'dave_the_longer_named', 'eve']
// --- the checks -------------------------------------------------------------
/** Where every open popup is, against the window. */
const MEASURE = `(() => {
const w = innerWidth, h = innerHeight
return [...document.querySelectorAll(${JSON.stringify(POPUPS)})]
.filter((el) => el.getClientRects().length > 0)
.map((el) => {
const r = el.getBoundingClientRect()
return {
what: el.className || el.getAttribute('role'),
left: Math.round(r.left), top: Math.round(r.top),
right: Math.round(r.right), bottom: Math.round(r.bottom),
inside: r.left >= -1 && r.top >= -1 && r.right <= w + 1 && r.bottom <= h + 1,
viewport: w + 'x' + h
}
})
})()`
/** A right-click at the window's far corner, on whatever element is named. */
const rightClickInCorner = (selector, text) => `(() => {
const el = [...document.querySelectorAll(${JSON.stringify(selector)})]
.find((e) => ${JSON.stringify(text ?? '')} === '' || (e.innerText + " " + (e.title || "")).includes(${JSON.stringify(text ?? '')}))
if (!el) return false
el.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: innerWidth - 2, clientY: innerHeight - 2, button: 2 }))
return true
})()`
const SCENARIOS = [
{ name: 'emoji picker', open: click('button[title="Emoji"]') },
{ name: 'channel list menu, opened in the corner', open: rightClickInCorner('.buffer-row', '#ui') },
{ name: 'message menu, opened in the corner', open: rightClickInCorner('.message-row', 'line 12') },
{ name: 'member menu, opened in the corner', open: rightClickInCorner('.nick-row', 'alice') },
{ name: 'mentions inbox', open: click('button[title="Mentions"]') },
{ name: 'status popout', open: click('.user-identity-button') }
]
async function closePopups(page) {
for (const type of ['keyDown', 'keyUp']) {
await page.call('Input.dispatchKeyEvent', { type, key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 })
}
await sleep(150)
// Whatever Escape did not close, a click on empty space does.
if ((await page.evaluate(MEASURE)).length > 0) {
for (const type of ['mousePressed', 'mouseReleased']) {
await page.call('Input.dispatchMouseEvent', { type, x: 5, y: 5, button: 'left', clickCount: 1 })
}
await sleep(150)
}
}
async function main() {
const irc = await fakeIrc({
people: PEOPLE,
onJoin: (channel, _nick, send) => {
for (let i = 0; i < 12; i++) {
const who = PEOPLE[i % PEOPLE.length]
send(`:${who}!u@h PRIVMSG ${channel} :line ${i + 1} from ${who}, long enough to wrap on a narrow window`)
}
}
})
const failures = []
let checked = 0
const { page, stop } = await launchApp()
try {
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#ui'], waitFor: 'line 12' })
for (const size of SIZES) {
await page.call('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: false })
await sleep(400)
for (const scenario of SCENARIOS) {
await closePopups(page)
const opened = await page.evaluate(scenario.open)
const label = `${scenario.name} at ${size.width}x${size.height}`
if (!opened) {
failures.push(`${label}: nothing to open it from`)
continue
}
let seen = false
let started = 0
for (const at of SAMPLES) {
await sleep(at - started)
started = at
const rects = await page.evaluate(MEASURE)
if (rects.length > 0) seen = true
for (const r of rects) {
checked++
if (!r.inside) {
failures.push(
`${label}, ${at}ms: "${r.what}" at ${r.left},${r.top} to ${r.right},${r.bottom} is outside the ${r.viewport} window`
)
}
}
}
if (!seen) failures.push(`${label}: it did not open`)
else console.log(` ok ${label}`)
}
}
} finally {
await stop()
irc.close()
}
if (failures.length > 0) {
console.error(`\n${failures.length} layout failure(s):`)
for (const f of failures) console.error(` FAIL ${f}`)
process.exit(1)
}
console.log(`\n${checked} measurements, every popup inside the window.`)
}
main().catch((e) => {
console.error(e)
process.exit(1)
})