Files
moho/scripts/tray-check.mjs

275 lines
15 KiB
JavaScript

#!/usr/bin/env node
/**
* Checks the tray against the running app: what its icon, tooltip and menu say
* for what is waiting, what its entries do, and that "Restart moho" brings back
* moho and a daemon of its own.
*
* A tray cannot be photographed, so this reads what main last gave it through a
* hook that exists only under MOHO_UI_SHOTS, and presses its entries the same
* way. Main is reached over the Node inspector the app is started with.
*
* npm run test:tray (builds first; needs the daemon built too)
*/
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import { cdp, fakeIrc, freePort, launchApp, openIrcChannel, sleep, until } from './lib/harness.mjs'
let failures = 0
const check = (ok, what, detail = '') => {
console.log(`${ok ? 'ok ' : 'FAIL'} ${what}${ok ? '' : detail ? ` - ${detail}` : ''}`)
if (!ok) failures++
}
async function mainProcess(inspectPort) {
for (let i = 0; i < 60; i++) {
try {
const list = await (await fetch(`http://127.0.0.1:${inspectPort}/json/list`)).json()
const target = list.find((t) => t.webSocketDebuggerUrl)
if (target) return cdp(target.webSocketDebuggerUrl)
} catch {
/* not up yet */
}
await sleep(500)
}
throw new Error('main never offered its inspector')
}
/** The nobilis processes that belong to this run, by the runtime directory in their environment. */
function daemons(scratch) {
const out = []
for (const entry of fs.readdirSync('/proc')) {
if (!/^\d+$/.test(entry)) continue
try {
const exe = fs.readlinkSync(`/proc/${entry}/exe`)
if (!exe.endsWith('/nobilis')) continue
if (fs.readFileSync(`/proc/${entry}/environ`, 'utf8').includes(`XDG_RUNTIME_DIR=${scratch}/run`)) out.push(Number(entry))
} catch {
/* gone, or not ours */
}
}
return out
}
// A run that hangs is a failure, and must not leave an app behind it.
setTimeout(() => {
console.log('FAIL timed out')
process.exit(1)
}, 150000).unref()
const inspectPort = await freePort()
const { page, stop, port } = await launchApp({ env: { MOHO_UI_SHOTS: '1' }, args: [`--inspect=${inspectPort}`] })
let scratchDir = ''
try {
const main = await mainProcess(inspectPort)
const tray = (expr) => main.evaluate(`(() => { const t = globalThis.__mohoTray; return ${expr} })()`)
await until(main, 'globalThis.__mohoTray', 'the tray')
scratchDir = await main.evaluate('process.env.HOME')
// Nothing waiting.
await tray('t.update({ unread: 0, dms: 0, mentions: 0 })'.replace('t.', 't.'))
check(/^tray-(light|dark)\.png$/.test(await tray('t.icon()')), 'idle: the plain bubble', await tray('t.icon()'))
check((await tray('t.menu()[0].label')) === 'Nothing new', 'idle: the menu says nothing is new')
check((await tray('t.tooltip()')) === 'moho', 'idle: the tooltip is just moho')
// Something waiting: the count is on the icon, and the menu says what.
await tray('t.update({ unread: 3, dms: 2, mentions: 1 })')
check(/^tray-(light|dark)-3\.png$/.test(await tray('t.icon()')), 'waiting: the icon carries the count', await tray('t.icon()'))
check((await tray('t.tooltip()')) === 'moho - 2 direct messages, 1 mention', 'waiting: the tooltip says what', await tray('t.tooltip()'))
check((await tray('t.menu()[0].label')) === '2 direct messages, 1 mention - open', 'waiting: the menu says what', await tray('t.menu()[0].label'))
await tray('t.update({ unread: 40, dms: 40, mentions: 0 })')
check(/-more\.png$/.test(await tray('t.icon()')), 'a great many: the icon says 9+')
await tray('t.update({ unread: 0, dms: 0, mentions: 0 })')
// The entries, and that none of them is daemon plumbing.
const labels = await tray('t.menu().map((i) => i.label ?? "-")')
check(!labels.some((l) => /daemon/i.test(l)), 'the menu offers no daemon plumbing', labels.join(' | '))
check(labels.includes('Restart moho'), 'the menu offers Restart moho')
// Settings opens in the window.
await tray('t.click(["Settings"])')
await until(page, 'window.__mohoShots.state().activePanel === "settings"', 'Settings to open')
check(true, 'Settings opens the settings page')
// A status goes to the window to carry out.
await page.evaluate('window.__trayGot = []; window.moho.onTrayCommand((c, a) => window.__trayGot.push([c, a])); true')
await tray('t.click(["Status", "Do not disturb"])')
await until(page, 'window.__trayGot.length', 'the status to reach the window')
check(JSON.stringify(await page.evaluate('window.__trayGot[0]')) === '["status","dnd"]', 'a status is handed to the window')
// There is no notifications switch of its own: Do not disturb is that.
check(!labels.some((l) => /notification/i.test(l)), 'the menu has no notifications switch of its own', labels.join(' | '))
// With an IRC account connected - which has away and back, and no invisible -
// the menu offers what IRC can be set to, and the daemon takes it.
const aways = []
const irc = await fakeIrc({
people: ['bob'],
onJoin: (channel, _nick, send) => send(`:bob!b@h PRIVMSG ${channel} :hello there`),
onLine: (line) => {
if (/^AWAY/i.test(line)) aways.push(line)
}
})
try {
await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#lobby'], waitFor: 'hello there' })
await until(main, 'globalThis.__mohoTray.menu().find((i) => i.label === "Status").submenu.length === 3', 'the menu to offer only IRC\'s statuses')
const offered = await tray('t.menu().find((i) => i.label === "Status").submenu.map((i) => i.label)')
check(JSON.stringify(offered) === '["Online","Idle","Do not disturb"]', 'IRC alone: no Invisible is offered', JSON.stringify(offered))
const statusOf = () => page.evaluate('window.moho.rpc("listAccounts", {}).then((a) => a.find((x) => x.id.startsWith("checker")).status)')
await tray('t.click(["Status", "Idle"])')
await until(page, 'window.moho.rpc("listAccounts", {}).then((a) => a.find((x) => x.id.startsWith("checker")).status === "idle")', 'IRC to go idle')
check(true, 'Idle from the tray sets the IRC account idle')
await tray('t.click(["Status", "Do not disturb"])')
await until(page, 'window.moho.rpc("listAccounts", {}).then((a) => a.find((x) => x.id.startsWith("checker")).status === "dnd")', 'IRC to go to Do not disturb')
check((await statusOf()) === 'dnd', 'Do not disturb from the tray sets it')
// Going idle when the person steps away, and only when they are online.
const shown = () => page.evaluate('window.moho.rpc("listAccounts", {}).then((a) => { const x = a.find((y) => y.id.startsWith("checker")); return { status: x.status, auto: !!x.autoIdle } })')
const waitFor = (expr, what) => until(page, `window.moho.rpc("listAccounts", {}).then((a) => { const x = a.find((y) => y.id.startsWith("checker")); return ${expr} })`, what)
await tray('t.click(["Status", "Online"])')
await waitFor('x.status === "online"', 'online again')
await tray('t.idle(true)')
await waitFor('x.status === "idle" && x.autoIdle === true', 'an online account to go idle when its person steps away')
check(true, 'stepping away turns an online account idle, as automatic')
await sleep(500)
check(aways.some((l) => /AWAY :?Idle/i.test(l)), 'and IRC is told it is away', JSON.stringify(aways))
await tray('t.idle(false)')
await waitFor('x.status === "online" && !x.autoIdle', 'it to come back')
check(aways.some((l) => /^AWAY\s*$/i.test(l)), 'coming back puts it right without anybody touching it', JSON.stringify(aways))
// What was chosen is never moved.
await tray('t.click(["Status", "Do not disturb"])')
await waitFor('x.status === "dnd"', 'Do not disturb')
await tray('t.idle(true)')
await sleep(800)
let now = await shown()
check(now.status === 'dnd' && !now.auto, 'Do not disturb is kept when they step away', JSON.stringify(now))
await tray('t.idle(false)')
await sleep(500)
now = await shown()
check(now.status === 'dnd', 'and when they come back', JSON.stringify(now))
await tray('t.click(["Status", "Idle"])')
await waitFor('x.status === "idle" && !x.autoIdle', 'a chosen Idle')
await tray('t.idle(true)')
await tray('t.idle(false)')
await sleep(500)
now = await shown()
check(now.status === 'idle' && !now.auto, 'a chosen Idle stays until Online is chosen', JSON.stringify(now))
await tray('t.click(["Status", "Online"])')
await waitFor('x.status === "online"', 'online again')
// The tray's number is the window's: it follows what is waiting, by the rail's rule.
const stage = (patch) => page.evaluate(`(() => { const s = window.__mohoShots.state(); const id = s.buffers.find((b) => b.kind === 'channel' && b.id.startsWith('checker')).id; window.__chan = id; window.__mohoShots.patch({ buffers: s.buffers.map((b) => (b.id === id ? { ...b, ...(${JSON.stringify(patch)}) } : b)) }); return id })()`)
const counted = async (want, what) => {
await until(main, `(() => { const s = globalThis.__mohoTray.state(); return s.mentions === ${want.mentions} && s.dms === ${want.dms} })()`, what)
check(true, what)
}
await tray('t.update({ unread: 0, dms: 0, mentions: 0 })')
const chan = await stage({ unread: 33, mentions: 2, highlight: true })
await counted({ mentions: 2, dms: 0 }, 'two mentions in a busy channel make the tray say two, not thirty-three')
check((await tray('t.state().latest')) === chan, 'and the menu opens that channel')
// Said the way another window would say it, through main.
const chanId = await page.evaluate('window.__chan')
await tray(`t.pref('mutedBuffers', [${JSON.stringify(chanId)}])`)
await counted({ mentions: 2, dms: 0 }, 'muting it leaves its mentions on the tray - a mute stops the popups, not the count')
await stage({ unread: 40, mentions: 0, highlight: false })
await counted({ mentions: 0, dms: 0 }, 'and what else is said in it counts for nothing')
await tray("t.pref('mutedBuffers', [])")
await stage({ unread: 33, mentions: 2, highlight: true })
await counted({ mentions: 2, dms: 0 }, 'unmuting changes nothing about the number')
await stage({ unread: 0, mentions: 0, highlight: false })
await counted({ mentions: 0, dms: 0 }, 'reading it somewhere else clears the tray as it clears the window')
await page.evaluate(`(() => { const s = window.__mohoShots.state(); window.__mohoShots.patch({ buffers: [...s.buffers, { id: 'checker@localhost|bobdm', accountId: s.accounts[0].id, kind: 'dm', name: 'bob', lastActivityTs: 1, unread: 3, highlight: false, mentions: 0 }] }); return true })()`)
await counted({ mentions: 0, dms: 3 }, 'a direct message counts for each unread message in it')
check(/3 direct messages/.test(await tray('t.tooltip()')), 'and the tooltip says so', await tray('t.tooltip()'))
await page.evaluate(`(() => { const s = window.__mohoShots.state(); window.__mohoShots.patch({ buffers: s.buffers.filter((b) => b.id !== 'checker@localhost|bobdm') }); return true })()`)
await counted({ mentions: 0, dms: 0 }, 'and the tray is empty again')
} finally {
irc.close()
}
// The tray being redrawn - which it is whenever something unread changes - must
// not move the window: somebody who has chosen another server stays on it. The
// redraw tells the window the link is up, and taking each for a reconnection
// sent the pane back to the server of whatever conversation was open.
const away = await page.evaluate('window.__mohoShots.state().activeGroupId')
await page.evaluate('window.__mohoShots.store.selectGroup("~mentions")')
for (let i = 0; i < 6; i++) {
await tray(`t.update({ unread: ${i}, dms: ${i}, mentions: 0 })`)
await sleep(200)
}
await sleep(1500)
const stayed = await page.evaluate('window.__mohoShots.state().activeGroupId')
check(stayed === '~mentions', 'a redrawn tray leaves the chosen server chosen', `${away} -> ${stayed}`)
await tray('t.update({ unread: 0, dms: 0, mentions: 0 })')
// Using a window is heard by main - and only that it happened.
const heardBefore = await tray('t.lastActivity()')
await sleep(6000)
await page.call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 300, y: 300, button: 'none', clickCount: 0 })
await sleep(500)
const heardAfter = await tray('t.lastActivity()')
check(heardAfter > heardBefore, 'moving the pointer in a window is heard as being here', `${heardBefore} -> ${heardAfter}`)
// Restart brings back moho and a daemon of its own.
const before = daemons(scratchDir)
const pidBefore = await main.evaluate('process.pid')
check(before.length === 1, 'one daemon is running', String(before))
// Not waited for: the app quits under this call, and the inspector with it.
void tray('t.click(["Restart moho"])').catch(() => {})
// And the inspector let go, because a process that is being inspected waits
// for the inspector to disconnect before it will exit.
await sleep(500)
main.close()
let pidAfter = pidBefore
let again = null
for (let i = 0; i < 80 && pidAfter === pidBefore; i++) {
await sleep(500)
try {
again = await mainProcess(inspectPort)
pidAfter = await again.evaluate('process.pid')
} catch {
/* between the two */
}
}
check(pidAfter !== pidBefore, 'moho came back as a new process', `${pidBefore} -> ${pidAfter}`)
await sleep(4000)
const after = daemons(scratchDir)
check(after.length === 1 && !before.includes(after[0]), 'with a daemon of its own, the old one gone', `${before} -> ${after}`)
// Whatever it started has to go with this run.
try {
for (const pid of daemons(scratchDir)) process.kill(pid, 'SIGTERM')
} catch {
/* gone */
}
} catch (e) {
console.log('FAIL', e.message)
failures++
} finally {
// The relaunched copy is not the one the harness started, so it is not in the
// group stop() ends. It is found by the debugging port it was told to use -
// it shows no environment to be found by - and what it started goes when it
// does. Ended first, and twice since it may still be starting, because it
// writes into the directory stop() is about to remove.
for (let i = 0; i < 2; i++) {
await sleep(1500)
try {
execFileSync('pkill', ['-9', '-f', `remote-debugging-port=${port}`])
} catch {
/* none left */
}
for (const entry of fs.readdirSync('/proc')) {
if (!/^\d+$/.test(entry) || Number(entry) === process.pid || !scratchDir) continue
try {
if (fs.readFileSync(`/proc/${entry}/environ`, 'utf8').includes(`HOME=${scratchDir}\0`)) process.kill(Number(entry), 'SIGKILL')
} catch {
/* gone, or not ours to read */
}
}
}
await sleep(1500)
await stop().catch(() => {})
if (scratchDir) fs.rmSync(scratchDir, { recursive: true, force: true })
}
console.log(failures ? `${failures} failed` : 'all passed')
process.exit(failures ? 1 : 0)