Add OPNsense WebGUI regression tests

This commit is contained in:
ms2k 2026-08-07 00:29:07 +09:00
commit 1ef0b80415
8 changed files with 323 additions and 0 deletions

4
.gitignore vendored
View file

@ -22,6 +22,10 @@ dist/
artifacts/
coverage/
*.log
tests/ui/.runtime/
tests/ui/node_modules/
tests/ui/playwright-report/
tests/ui/test-results/
# Editor and agent state
.claude/

24
tests/ui/README.md Normal file
View file

@ -0,0 +1,24 @@
# OPNsense WebGUI integration tests
These Playwright tests use a disposable OPNsense deployment. They create and delete NordVPN enrollment records and require `OPNSENSE_UI_TEST_ALLOW_MUTATION=1`.
Start a QEMU fixture after building a package. The launcher needs a compatible OPNsense base image and transfer disk, neither of which is stored in this repository.
```sh
NORDVPN_PACKAGE_PATH=/path/to/os-nordvpn.pkg \
OPNSENSE_VM_IMAGE=/path/to/opnsense.qcow2 \
OPNSENSE_TRANSFER_IMAGE=/path/to/transfer.img \
tests/ui/start-opnsense.sh
```
In another terminal, run the browser suite against the disposable WebGUI:
```sh
OPNSENSE_UI_BASE_URL=https://127.0.0.1:18443 \
OPNSENSE_UI_USERNAME=root \
OPNSENSE_UI_PASSWORD=... \
OPNSENSE_UI_TEST_ALLOW_MUTATION=1 \
sh -c 'cd tests/ui && pnpm install --frozen-lockfile && pnpm test'
```
The suite mocks only the countries API response with synthetic values. It covers populated country options, failure-policy labels, native alias selectors, Add/Edit/Clone state restoration, and the selector menu/footer layout.

18
tests/ui/guest-setup.php Normal file
View file

@ -0,0 +1,18 @@
<?php
require_once '/usr/local/etc/inc/config.inc';
use OPNsense\Core\Config;
use OPNsense\Firewall\Alias;
$aliases = new Alias();
$alias = $aliases->aliases->alias->add();
$alias->setNodes([
'enabled' => '1',
'name' => 'UI_TEST_NETWORK',
'type' => 'network',
'proto' => 'IPv4',
'content' => '192.0.2.0/24',
]);
$aliases->serializeToConfig();
Config::getInstance()->save(['description' => 'Add synthetic UI test alias']);

10
tests/ui/package.json Normal file
View file

@ -0,0 +1,10 @@
{
"name": "opnsense-nordvpn-ui-tests",
"private": true,
"scripts": {
"test": "playwright test"
},
"devDependencies": {
"@playwright/test": "1.61.1"
}
}

View file

@ -0,0 +1,18 @@
const { defineConfig } = require('@playwright/test');
const baseURL = process.env.OPNSENSE_UI_BASE_URL;
if (!baseURL) {
throw new Error('Set OPNSENSE_UI_BASE_URL to the disposable OPNsense WebGUI URL.');
}
module.exports = defineConfig({
testDir: './specs',
timeout: 30000,
workers: 1,
use: {
baseURL,
ignoreHTTPSErrors: true,
trace: 'retain-on-failure',
},
});

51
tests/ui/pnpm-lock.yaml generated Normal file
View file

@ -0,0 +1,51 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
'@playwright/test':
specifier: 1.61.1
version: 1.61.1
packages:
'@playwright/[email protected]':
resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==}
engines: {node: '>=18'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
[email protected]:
resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
engines: {node: '>=18'}
hasBin: true
snapshots:
'@playwright/[email protected]':
dependencies:
playwright: 1.61.1
[email protected]:
optional: true
[email protected]: {}
[email protected]:
dependencies:
playwright-core: 1.61.1
optionalDependencies:
fsevents: 2.3.2

View file

@ -0,0 +1,115 @@
const { test, expect } = require('@playwright/test');
const username = process.env.OPNSENSE_UI_USERNAME;
const password = process.env.OPNSENSE_UI_PASSWORD;
if (!username || !password) {
throw new Error('Set OPNSENSE_UI_USERNAME and OPNSENSE_UI_PASSWORD for the disposable OPNsense deployment.');
}
if (process.env.OPNSENSE_UI_TEST_ALLOW_MUTATION !== '1') {
throw new Error('Set OPNSENSE_UI_TEST_ALLOW_MUTATION=1 to allow enrollment changes in the disposable deployment.');
}
test('renders and preserves NordVPN enrollment controls', async ({ page }) => {
const errors = [];
page.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text());
});
page.on('pageerror', (error) => errors.push(error.stack || error.message));
page.on('response', (response) => {
if (response.status() >= 400) errors.push(`${response.status()} ${response.url()}`);
});
await page.route('**/api/nordvpn/service/countries', async (route) => {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
status: 'ok',
countries: [
{ code: 'XA', name: 'Synthetic A' },
{ code: 'XB', name: 'Synthetic B' },
],
}),
});
});
await page.goto('/');
await page.locator('#usernamefld').fill(username);
await page.locator('#passwordfld').fill(password);
await page.getByRole('button', { name: 'Login' }).click();
await page.goto('/ui/nordvpn');
await expect(page.getByRole('heading', { name: 'NordVPN egress enrollment' })).toBeVisible();
const countries = page.locator('[id="settings.general.countries"]');
await expect(countries.locator('option[value="XA"]')).toHaveText('Synthetic A');
await expect(countries.locator('option[value="XB"]')).toHaveText('Synthetic B');
await expect(countries.locator('xpath=..').locator('button.dropdown-toggle')).toBeVisible();
const failurePolicy = page.locator('[id="settings.general.failure_policy"]');
await expect(failurePolicy.locator('option[value="fail_closed"]')).toHaveText('Block traffic');
await expect(failurePolicy.locator('option[value="fail_open"]')).toHaveText('Use direct egress');
const grid = page.getByRole('grid');
const deleteButtons = grid.getByRole('button', { name: 'Delete', exact: true });
while (await deleteButtons.count()) {
const before = await deleteButtons.count();
await deleteButtons.first().click();
const removal = page.getByRole('dialog', { name: 'Remove' });
await removal.getByRole('button', { name: 'Yes' }).click();
await expect(deleteButtons).toHaveCount(before - 1);
}
await page.getByRole('button', { name: 'Add', exact: true }).click();
const dialog = page.locator('.modal:visible');
await expect(dialog).toBeVisible();
await dialog.locator('[id="enrollment.interfaces"]').selectOption('lan');
const source = dialog.locator('select[for="enrollment.source"]');
const destination = dialog.locator('select[for="enrollment.destination"]');
await expect(source).toBeVisible();
await expect(destination).toBeVisible();
await expect(source.locator('option[value="UI_TEST_NETWORK"]')).toHaveText('UI_TEST_NETWORK');
const destinationPicker = destination.locator('xpath=..');
const destinationToggle = destinationPicker.locator('button.dropdown-toggle');
await destinationToggle.click();
const destinationMenu = destinationPicker.locator('.dropdown-menu.open').first();
await expect(destinationMenu).toBeVisible();
const menuBox = await destinationMenu.boundingBox();
const footerBox = await dialog.locator('.modal-footer').boundingBox();
expect(menuBox.y + menuBox.height).toBeLessThanOrEqual(footerBox.y);
await destinationToggle.click();
await source.selectOption('UI_TEST_NETWORK');
await destination.selectOption('any');
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await expect(dialog).toBeHidden();
await page.reload();
await expect(page.getByRole('heading', { name: 'NordVPN egress enrollment' })).toBeVisible();
const row = page.getByRole('row').filter({ hasText: 'LAN' }).filter({ hasText: 'UI_TEST_NETWORK' }).first();
await expect(row).toBeVisible();
await row.getByRole('button', { name: 'Edit' }).click();
await expect(dialog).toBeVisible();
await expect(dialog.locator('select[for="enrollment.source"]')).toHaveValues(['UI_TEST_NETWORK']);
await expect(dialog.locator('select[for="enrollment.destination"]')).toHaveValues(['any']);
await dialog.getByRole('button', { name: 'Close' }).click();
await expect(dialog).toBeHidden();
await row.getByRole('button', { name: 'Clone' }).click();
await expect(dialog).toBeVisible();
await expect(dialog.locator('select[for="enrollment.source"]')).toHaveValues(['UI_TEST_NETWORK']);
await expect(dialog.locator('select[for="enrollment.destination"]')).toHaveValues(['any']);
await dialog.getByRole('button', { name: 'Close' }).click();
await expect(dialog).toBeHidden();
const beforeDelete = await deleteButtons.count();
await row.getByRole('button', { name: 'Delete' }).click();
const removal = page.getByRole('dialog', { name: 'Remove' });
await expect(removal).toBeVisible();
await removal.getByRole('button', { name: 'Yes' }).click();
await expect(deleteButtons).toHaveCount(beforeDelete - 1);
expect(errors).toEqual([]);
});

83
tests/ui/start-opnsense.sh Executable file
View file

@ -0,0 +1,83 @@
#!/bin/sh
set -eu
umask 077
: "${NORDVPN_PACKAGE_PATH:?Set NORDVPN_PACKAGE_PATH to the package under test.}"
: "${OPNSENSE_VM_IMAGE:?Set OPNSENSE_VM_IMAGE to the OPNsense qcow2 base image.}"
: "${OPNSENSE_TRANSFER_IMAGE:?Set OPNSENSE_TRANSFER_IMAGE to the OPNsense transfer disk.}"
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
runtime_dir="$script_dir/.runtime"
ui_port=${OPNSENSE_UI_PORT:-18443}
test -r "$NORDVPN_PACKAGE_PATH"
test -r "$OPNSENSE_VM_IMAGE"
test -r "$OPNSENSE_TRANSFER_IMAGE"
mkdir -p "$runtime_dir"
rm -f "$runtime_dir/opnsense.qcow2" "$runtime_dir/serial.in" "$runtime_dir/serial.out" \
"$runtime_dir/serial.log" "$runtime_dir/transfer.img"
mkfifo "$runtime_dir/serial.in" "$runtime_dir/serial.out"
qemu-img create -q -f qcow2 -F qcow2 -b "$OPNSENSE_VM_IMAGE" "$runtime_dir/opnsense.qcow2"
cp "$OPNSENSE_TRANSFER_IMAGE" "$runtime_dir/transfer.img"
mcopy -o -i "$runtime_dir/transfer.img" "$NORDVPN_PACKAGE_PATH" ::os-nordvpn.pkg
mcopy -o -i "$runtime_dir/transfer.img" "$script_dir/guest-setup.php" ::guest-setup.php
cleanup()
{
set +e
[ -n "${qemu_pid:-}" ] && kill "$qemu_pid" 2>/dev/null
}
trap cleanup EXIT INT TERM
qemu-system-x86_64 \
-machine q35,accel=tcg \
-smp 2 \
-m 2048 \
-display none \
-monitor none \
-drive file="$runtime_dir/opnsense.qcow2",if=virtio,format=qcow2 \
-drive file="$runtime_dir/transfer.img",if=virtio,format=raw,readonly=on \
-netdev user,id=wan,net=192.168.1.0/24,dhcpstart=192.168.1.100,host=192.168.1.254,hostfwd=tcp:127.0.0.1:"$ui_port"-192.168.1.1:443 \
-device virtio-net-pci,netdev=wan \
-chardev pipe,id=console,path="$runtime_dir/serial" \
-serial chardev:console &
qemu_pid=$!
cat "$runtime_dir/serial.out" >> "$runtime_dir/serial.log" &
wait_for()
{
pattern=$1
attempts=${2:-300}
while ! grep -Fq "$pattern" "$runtime_dir/serial.log" 2>/dev/null; do
attempts=$((attempts - 1))
[ "$attempts" -gt 0 ] || {
echo "timed out waiting for: $pattern" >&2
exit 1
}
sleep 1
done
}
send()
{
printf '%b' "$1" > "$runtime_dir/serial.in"
}
wait_for 'Autoboot in' 180
send ' '
sleep 1
send '2\n'
wait_for 'Enter full pathname of shell' 180
send '\n'
wait_for '# ' 60
send "mount -uw /\n"
sleep 2
send "sh /etc/rc\n"
wait_for 'root@OPNsense:/ #' 300
send "mount -o ro -t msdosfs /dev/vtbd1 /mnt && pkg add -f /mnt/os-nordvpn.pkg && php /mnt/guest-setup.php && umount /mnt && pfctl -d && echo UI_VM_READY\n"
wait_for 'UI_VM_READY' 120
printf '%s\n' "OPNSENSE_UI_BASE_URL=https://127.0.0.1:$ui_port"
wait "$qemu_pid"