275 lines
15 KiB
JavaScript
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)
|