diff --git a/test/ui/README.md b/test/ui/README.md index 4407da4..7f893a2 100644 --- a/test/ui/README.md +++ b/test/ui/README.md @@ -71,6 +71,24 @@ wire. | restart | the restart chip on a stack's page: a new podman install restarts unless stopped, an AppJail install starts at boot and its chip offers only the boot choices, Don't start at boot saved and kept; each policy saved, applied, in the compose and shown after a reload; a crash left down under Don't restart (crash restarts under the others SKIP while the host check fdescfs fails: without fdescfs on /dev/fd conmon cannot free the port) | t-restart, t-restart-aj | | lnms-admin | opt-in: LibreNMS's admin password is honoured as given (needs a librenms image from 2026-09-30 or later) | t-lnms | +### reboot (by hand) + +Start on boot across a real reboot of the host, one stack per rule on both +engines. Not in the suite and not run by the runner: the host reboots +between the two steps, and that is yours to do. + + cd /root/fjord-ui-tests # on the host, with reboot.js copied there + podman run --rm --network host -e STEP=setup -v $PWD/reboot.js:/app/t.js:ro \ + -v $PWD/out:/out ghcr.io/daemonless/playwright:latest node /app/t.js + shutdown -r now + # back up, once fjordd has started its stacks: + podman run --rm --network host -e STEP=check -v $PWD/reboot.js:/app/t.js:ro \ + -v $PWD/out:/out ghcr.io/daemonless/playwright:latest node /app/t.js + +Then delete the `t-rb-*` stacks. On a host with `podman_enable=YES`, +podman's own rc script also starts the `always` stack at boot and can +collide with fjordd's start. + Not here: `wire-persist` (the warning kept across a restart) needs the wrong-wire `lan-range` fixture, which netlab no longer has. diff --git a/test/ui/reboot.js b/test/ui/reboot.js new file mode 100644 index 0000000..0da9573 --- /dev/null +++ b/test/ui/reboot.js @@ -0,0 +1,112 @@ +// Start on boot follows each stack's restart policy, on both engines, across +// a real host reboot. Not in the suite: the host reboots between its two +// steps (see README). STEP=setup: one stack per rule, set through the page's +// chip and Stop. STEP=check: which came back. Delete the t-rb-* stacks by +// hand after. +const { chromium } = require('playwright-core'); +const BASE = 'http://127.0.0.1:3567'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const note = (id, ok, msg) => console.log(`${ok ? 'PASS' : 'FAIL'} ${id}: ${msg}`); +const detail = async (page, name) => (await page.request.get(BASE + '/api/stacks/' + name)).json(); +const chip = (page) => page.locator('[data-restart-policy] > button'); +const policy = (page) => chip(page).getAttribute('data-policy'); +const LABEL = { 'unless-stopped': 'Restart unless stopped', 'on-failure': 'Restart after a crash', no: "Don't restart" }; +async function settle(page, name) { + let d; + for (let i = 0; i < 80; i++) { d = await detail(page, name).catch(() => ({})); if (!d?.busy && (d?.status?.state === 'running' || d?.state?.last_failure)) break; await sleep(3000); } + return d; +} +async function install(page, name, engine, port) { + await page.goto(BASE + '/?t=' + Date.now() + '#/store', { waitUntil: 'networkidle' }); + await page.getByPlaceholder(/Search apps/).fill('openspeedtest'); + await sleep(800); + await page.getByRole('button', { name: /^Install/ }).first().click(); + const install = page.getByRole('button', { name: 'Install', exact: true }); + await install.waitFor(); + await page.getByLabel('Name').fill(name); + if (!(await page.locator('#WEB_PORT').isVisible().catch(() => false))) await page.getByRole('button', { name: /^Options/ }).first().click(); + await page.locator('#WEB_PORT').fill(port); + if (engine === 'appjail') { + const sel = page.locator('select').filter({ hasText: 'appjail' }).first(); + if (!(await sel.isVisible().catch(() => false))) await page.getByText('Advanced', { exact: true }).click(); + await sel.selectOption('appjail'); + await sleep(1500); + } + await install.click(); + const d = await settle(page, name); + note(`${engine}: ${name} installed`, d?.status?.state === 'running', `state ${d?.status?.state}`); + await page.goto(BASE + '/?t=' + Date.now() + '#/stacks/' + name, { waitUntil: 'networkidle' }); + await sleep(1500); +} +// [name, engine, port, chip choice (its words), stopped by the user, up after the reboot] +const CASES = [ + ['t-rb-keep', 'podman', '3131', '', false, true], + ['t-rb-stopped', 'podman', '3132', '', true, false], + ['t-rb-always', 'podman', '3133', 'Always restart', true, true], + ['t-rb-never', 'podman', '3134', "Don't restart", false, false], + ['t-rb-aj-always', 'appjail', '3135', 'Always start at boot', true, true], + ['t-rb-aj-never', 'appjail', '3136', "Don't start at boot", false, false], +]; +async function stopped(page, name) { + for (let i = 0; i < 40; i++) { + const d = await detail(page, name).catch(() => ({})); + if (!d?.busy && d?.status?.state === 'stopped') return true; + await sleep(3000); + } + return false; +} +(async () => { + const b = await chromium.launch({ args: ['--no-sandbox'] }); + const page = await (await b.newContext({ viewport: { width: 1400, height: 1100 } })).newPage(); + page.setDefaultTimeout(30000); + try { + if (process.env.STEP === 'setup') { + for (const [name, engine, port, choice, stop] of CASES) { + await install(page, name, engine, port); + // A podman install watches that it stays up for a minute; the page + // re-renders the whole time, so wait for it before using the chip. + if (engine === 'podman') await page.getByText(/has stayed up/).waitFor({ timeout: 150000 }).catch(() => {}); + if (choice) { + await chip(page).click(); + await page.getByRole('menuitemradio', { name: choice }).click(); + await sleep(1500); + // A compose change is taken at Apply; a boot-only one needs none. + const apply = page.getByRole('button', { name: 'Apply Changes', exact: true }); + if (engine === 'podman') { + await apply.click(); + await sleep(2000); + await settle(page, name); + } + } + if (stop) { + await page.goto(BASE + '/?t=' + Date.now() + '#/stacks/' + name, { waitUntil: 'networkidle' }); + await sleep(1500); + await page.getByRole('button', { name: 'Stop…' }).click(); + await page.getByRole('button', { name: 'Stop', exact: true }).click(); + } + const d = await detail(page, name); + const ok = stop ? await stopped(page, name) : d.status?.state === 'running'; + note(`setup ${name}: ${engine}, ${choice || 'the default'}${stop ? ', stopped' : ''}`, ok, `policy ${d.restart?.policy}, state ${(await detail(page, name)).status?.state}`); + } + } else { + for (const [name, engine, , choice, stop, up] of CASES) { + // A stack meant to stay down is never running: settle would wait out + // its four minutes on each. + const d = (up ? await settle(page, name) : await detail(page, name)) || {}; + const state = d.status?.state; + const desc = `${engine}, ${choice || 'the default'}${stop ? ', stopped before' : ''}`; + note(`${name} (${desc}): ${up ? 'up' : 'down'} after the reboot`, up ? state === 'running' : state !== 'running', `state ${state}, desired ${d.state?.desired_state}`); + // Left down by its policy, it reads as stopped, not as a stack that + // failed to come up. + if (!up && !stop) note(`${name}: recorded as stopped, not failed`, d.state?.desired_state === 'stopped', `desired ${d.state?.desired_state}`); + } + await page.goto(BASE + '/?t=' + Date.now() + '#/stacks', { waitUntil: 'networkidle' }); + await sleep(2000); + await page.screenshot({ path: '/out/reboot-stacks.png', fullPage: true }); + } + } catch (e) { + note('run', false, e.message.split('\n')[0]); + await page.screenshot({ path: '/out/reboot-error.png', fullPage: true }).catch(() => {}); + } + await b.close(); +})();