Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions test/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
112 changes: 112 additions & 0 deletions test/ui/reboot.js
Original file line number Diff line number Diff line change
@@ -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();
})();
Loading