Files
moho/scripts/lib/harness.mjs

309 lines
11 KiB
JavaScript

/**
* What the scripts that drive the real app share: a small fake IRC server to
* give it something to show, the app launched against a throwaway home, and a
* line to its window over the DevTools protocol.
*
* The app never touches a real account or the daemon of a moho already
* running: HOME, the XDG directories and - because the daemon's socket lives
* there - XDG_RUNTIME_DIR are all its own.
*/
import { spawn, execFileSync } from 'node:child_process'
import fs from 'node:fs'
import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import electronPath from 'electron'
export const root = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))))
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
// --- a fake IRC server ---------------------------------------------------
/**
* A server that registers anybody and, when they join a channel, says
* whatever `onJoin` says there.
*
* `caps` are offered and granted whole. `server-time` among them lets a line
* carry when it was said (`at(date)` builds the tag), which is how history
* from other days reaches the client.
*/
export function fakeIrc({ people = [], caps = [], onJoin, onLine } = {}) {
const server = net.createServer((socket) => {
let nick = 'checker'
let negotiating = false
let registered = false
const send = (line) => socket.write(line + '\r\n')
const welcome = () => {
if (registered) return
registered = true
send(`:fake 001 ${nick} :Welcome`)
send(`:fake 376 ${nick} :End of MOTD`)
}
let buffered = ''
socket.on('error', () => {})
socket.on('data', (chunk) => {
buffered += chunk.toString()
let at
while ((at = buffered.indexOf('\n')) >= 0) {
const line = buffered.slice(0, at).replace(/\r$/, '')
buffered = buffered.slice(at + 1)
onLine?.(line, send)
const [command, ...args] = line.split(' ')
switch (command.toUpperCase()) {
case 'CAP':
if (args[0] === 'LS') {
negotiating = true
send(`:fake CAP * LS :${caps.join(' ')}`)
} else if (args[0] === 'REQ') {
send(`:fake CAP * ACK ${args.slice(1).join(' ')}`)
} else if (args[0] === 'END') {
negotiating = false
welcome()
}
break
case 'NICK':
nick = args[0]
break
case 'USER':
if (!negotiating) welcome()
break
case 'PING':
send(`:fake PONG fake ${args.join(' ')}`)
break
case 'JOIN':
for (const channel of args[0].split(',')) {
send(`:${nick}!u@h JOIN ${channel}`)
send(`:fake 353 ${nick} = ${channel} :${[nick, ...people].join(' ')}`)
send(`:fake 366 ${nick} ${channel} :End of NAMES`)
onJoin?.(channel, nick, send)
}
break
}
}
})
})
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server)))
}
/** The server-time tag for a moment. */
export const at = (date) => `@time=${date.toISOString()} `
// --- the app, and a line to it --------------------------------------------
export async function freePort() {
const probe = net.createServer()
await new Promise((r) => probe.listen(0, '127.0.0.1', r))
const { port } = probe.address()
await new Promise((r) => probe.close(r))
return port
}
async function pageTarget(port) {
for (let i = 0; i < 120; i++) {
try {
const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()
const page = list.find((t) => t.type === 'page' && !t.url.startsWith('devtools:'))
if (page) return page
} catch {
/* not up yet */
}
await sleep(500)
}
throw new Error('the app never opened a window')
}
export function cdp(url) {
const socket = new WebSocket(url)
let id = 0
const waiting = new Map()
const listeners = new Map()
socket.addEventListener('message', (e) => {
const msg = JSON.parse(e.data)
if (msg.id && waiting.has(msg.id)) {
const { resolve, reject } = waiting.get(msg.id)
waiting.delete(msg.id)
if (msg.error) reject(new Error(msg.error.message))
else resolve(msg.result)
} else if (msg.method) {
for (const fn of listeners.get(msg.method) ?? []) fn(msg.params)
}
})
const ready = new Promise((r) => socket.addEventListener('open', r))
const call = async (method, params = {}) => {
await ready
const n = ++id
socket.send(JSON.stringify({ id: n, method, params }))
return new Promise((resolve, reject) => waiting.set(n, { resolve, reject }))
}
const evaluate = async (expression) => {
const r = await call('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true })
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text)
return r.result.value
}
const on = (method, fn) => {
if (!listeners.has(method)) listeners.set(method, new Set())
listeners.get(method).add(fn)
return () => listeners.get(method).delete(fn)
}
return { call, evaluate, on, close: () => socket.close() }
}
/** Waits until an expression in the page is truthy. */
export async function until(page, expression, what, ms = 30000) {
const end = Date.now() + ms
while (Date.now() < end) {
if (await page.evaluate(`!!(${expression})`).catch(() => false)) return
await sleep(250)
}
throw new Error(`timed out waiting for ${what}`)
}
/** Clicks the first element matching `selector` whose text or title holds `text`. */
export const click = (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.click()
return true
})()`
/**
* On Hyprland, out of the way: a window the harness opens should not land on
* top of what the person running it is doing. Anywhere else, left alone.
*/
function moveAside(pid) {
if (!process.env.HYPRLAND_INSTANCE_SIGNATURE) return
try {
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j'], { encoding: 'utf8' }))
const descendants = new Set([pid])
// The window belongs to Electron's browser process, a child of the one
// spawned; walking /proc is enough to find it.
for (const entry of fs.readdirSync('/proc')) {
if (!/^\d+$/.test(entry)) continue
try {
const stat = fs.readFileSync(`/proc/${entry}/stat`, 'utf8')
const ppid = Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[1])
if (descendants.has(ppid)) descendants.add(Number(entry))
} catch {
/* gone */
}
}
for (const c of clients) {
if (!descendants.has(c.pid)) continue
execFileSync('hyprctl', [
'dispatch',
`hl.dsp.window.move({ window = "address:${c.address}", workspace = "${process.env.MOHO_HARNESS_WORKSPACE ?? '2'}", silent = true })`
])
}
} catch {
/* not this compositor's dialect; leave it */
}
}
/**
* The built app, against a scratch home, with a debugging port. Returns the
* page and a `stop()` that takes everything down again.
*/
export async function launchApp({ env = {}, args = [] } = {}) {
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'moho-ui-'))
const run = path.join(scratch, 'run')
fs.mkdirSync(run, { mode: 0o700 })
const port = await freePort()
// A CI runner's kernel refuses Chromium's sandbox (Ubuntu 24.04 restricts
// the unprivileged namespaces it needs); there it runs without, as an
// ordinary Chromium in a container does.
const flags = [`--remote-debugging-port=${port}`, ...(process.env.CI ? ['--no-sandbox'] : []), ...args]
const app = spawn(electronPath, [root, ...flags], {
cwd: root,
detached: true,
stdio: process.env.MOHO_UI_VERBOSE ? 'inherit' : 'ignore',
env: {
...process.env,
...env,
HOME: scratch,
XDG_CONFIG_HOME: path.join(scratch, 'config'),
XDG_CACHE_HOME: path.join(scratch, 'cache'),
XDG_DATA_HOME: path.join(scratch, 'data'),
XDG_STATE_HOME: path.join(scratch, 'state'),
XDG_RUNTIME_DIR: run,
// The runtime directory is moved so the daemon's socket is this run's
// own - which also moves where a Wayland client looks for the
// compositor. An absolute path keeps it pointing at the real one.
...(process.env.WAYLAND_DISPLAY && process.env.XDG_RUNTIME_DIR && !path.isAbsolute(process.env.WAYLAND_DISPLAY)
? { WAYLAND_DISPLAY: path.join(process.env.XDG_RUNTIME_DIR, process.env.WAYLAND_DISPLAY) }
: {})
}
})
const stop = async () => {
page?.close()
try {
process.kill(-app.pid, 'SIGTERM')
} catch {
/* already gone */
}
await sleep(1500)
try {
process.kill(-app.pid, 'SIGKILL')
} catch {
/* already gone */
}
// Whatever a crash left behind that is still pointed at this run's
// directory: Chromium's helpers can outlive the group they were started
// in, and a harness that leaves windows and daemons running is worse than
// one that fails.
for (const entry of fs.readdirSync('/proc')) {
if (!/^\d+$/.test(entry)) continue
try {
if (Number(entry) === process.pid) continue
const cmd = fs.readFileSync(`/proc/${entry}/cmdline`, 'utf8')
// The daemon is started by the app with only the environment to say
// whose it is.
const env = cmd.includes(scratch) ? '' : fs.readFileSync(`/proc/${entry}/environ`, 'utf8')
if (cmd.includes(scratch) || env.includes(`XDG_RUNTIME_DIR=${scratch}`)) process.kill(Number(entry), 'SIGKILL')
} catch {
/* gone, or not ours to read */
}
}
fs.rmSync(scratch, { recursive: true, force: true })
}
let page
try {
page = cdp((await pageTarget(port)).webSocketDebuggerUrl)
moveAside(app.pid)
await until(page, 'window.moho && document.querySelector("#root > *")', 'the window to load')
} catch (e) {
await stop()
throw e
}
return { page, stop, port }
}
/**
* An IRC account on the fake server, its channels joined, and the window
* showing the first of them.
*/
export async function openIrcChannel(page, { port, nick, channels, waitFor }) {
await until(page, 'window.moho.rpc("listAccounts", {}).then(() => true)', 'the daemon')
await page.evaluate(
`window.moho.rpc("addAccount", { nick: ${JSON.stringify(nick)}, host: "localhost", port: ${port}, ssl: false, autojoin: ${JSON.stringify(channels.join(','))} })`
)
await until(
page,
`window.moho.rpc("listBuffers", {}).then((b) => ${JSON.stringify(channels)}.every((c) => b.some((x) => x.id.endsWith("|" + c))))`,
'the channels'
)
await page.call('Page.reload')
await sleep(1000)
await until(page, 'document.querySelector("nav[aria-label=Servers] [aria-label*=\\"localhost\\"]")', 'the account in the rail')
await page.evaluate(click('nav[aria-label=Servers] [aria-label*="localhost"]'))
await until(page, `[...document.querySelectorAll(".buffer-row")].some((r) => (r.title || r.innerText).includes(${JSON.stringify(channels[0])}))`, 'the channel row')
await page.evaluate(click('.buffer-row', channels[0]))
await until(page, `[...document.querySelectorAll(".message-row")].some((r) => r.innerText.includes(${JSON.stringify(waitFor)}))`, 'the messages')
}