chore: fix github actions runner for e2e tests covering rclone

This commit is contained in:
garethgeorge
2026-07-10 02:45:27 -07:00
parent b4fb7adb26
commit aeade74b01
7 changed files with 106 additions and 16 deletions
+24
View File
@@ -73,6 +73,9 @@ jobs:
test-e2e-web:
runs-on: ubuntu-latest
env:
# Not RCLONE_*: rclone reads RCLONE_VERSION as its --version flag.
E2E_RCLONE_VERSION: v1.69.1
steps:
- uses: actions/checkout@v4
@@ -121,6 +124,27 @@ jobs:
- name: Install playwright chromium
run: cd webui && pnpm exec playwright install --with-deps chromium
# restic's rclone backend spawns the `rclone` binary; the rclone-repo
# e2e spec skips itself if rclone is absent, so provision it for coverage.
- name: Cache rclone
id: cache-rclone
uses: actions/cache@v4
with:
path: webui/e2e/.cache/rclone-bin
key: rclone-${{ env.E2E_RCLONE_VERSION }}
- name: Install rclone
run: |
dest="$PWD/webui/e2e/.cache/rclone-bin"
if [ ! -x "$dest/rclone" ]; then
mkdir -p "$dest"
zip="rclone-${E2E_RCLONE_VERSION}-linux-amd64"
curl -fsSL "https://downloads.rclone.org/${E2E_RCLONE_VERSION}/${zip}.zip" -o /tmp/rclone.zip
unzip -j -o /tmp/rclone.zip "${zip}/rclone" -d "$dest"
chmod +x "$dest/rclone"
fi
echo "$dest" >> "$GITHUB_PATH"
- name: Run e2e tests
run: |
cd webui
+9
View File
@@ -18,6 +18,7 @@
go goreleaser nodejs_22 pnpm
protobuf buf protoc-gen-go protoc-gen-go-grpc protoc-gen-connect-go
gnumake git restic rclone zsh oh-my-posh
act docker
];
in
{
@@ -57,6 +58,14 @@
restic
rclone
# Local GitHub Actions debugging: `act` runs the workflows in
# .github/workflows inside Docker containers (runner images are
# pinned in .actrc). `docker` is the client act talks to; a
# running Docker daemon is a host prerequisite (on NixOS enable
# `virtualisation.docker`).
act
docker
# Shell
zsh
oh-my-posh
+7 -4
View File
@@ -31,9 +31,10 @@ test.describe('add repo (CUJ2)', () => {
await dialog.getByTestId('add-repo-name').fill('my-repo');
await dialog.getByTestId('add-repo-uri').fill(backrest.repoPath('my-repo'));
// The URI field is a combobox that opens a suggestions popover on input;
// close it so it doesn't sit on top of the fields/buttons below it.
await page.keyboard.press('Escape');
// Dismiss the URI autocomplete popover by refocusing the name field. Do
// NOT press Escape: when the popover has no suggestions and never opened,
// Escape closes the whole dialog instead.
await dialog.getByTestId('add-repo-name').click();
await dialog.getByTestId('add-repo-password').fill(PASSWORD);
await dialog.getByTestId('add-repo-submit').click();
@@ -77,7 +78,9 @@ test.describe('add repo (CUJ2)', () => {
const uri = backrest.repoPath('my-repo');
await dialog.getByTestId('add-repo-name').fill('my-repo');
await dialog.getByTestId('add-repo-uri').fill(uri);
await page.keyboard.press('Escape');
// Refocus the name field to dismiss the autocomplete popover (Escape would
// close the dialog when the popover never opened).
await dialog.getByTestId('add-repo-name').click();
await dialog.getByTestId('add-repo-password').fill(PASSWORD);
await dialog.getByTestId('add-repo-test-config').click();
+4 -2
View File
@@ -36,8 +36,10 @@ test.describe('error paths', () => {
await dialog.getByTestId('add-repo-name').fill('bad-pw');
await dialog.getByTestId('add-repo-uri').fill(backrest.repoPath('existing-repo'));
// Close the URI autocomplete popover so it doesn't overlap other fields.
await page.keyboard.press('Escape');
// Dismiss the URI autocomplete popover by refocusing the name field. Do
// NOT press Escape: when the popover has no suggestions and never opened,
// Escape closes the whole dialog instead.
await dialog.getByTestId('add-repo-name').click();
await dialog.getByTestId('add-repo-password').fill('totally-wrong-password');
await dialog.getByTestId('add-repo-submit').click();
+51 -6
View File
@@ -1,4 +1,5 @@
import * as fs from 'node:fs/promises';
import { constants as fsConstants } from 'node:fs';
import * as path from 'node:path';
import { create } from '@bufbuild/protobuf';
import { test, expect } from '../harness/fixtures';
@@ -59,11 +60,32 @@ async function runBackupViaApi(
throw new Error(`backup for plan ${planId} did not succeed within ${timeoutMs}ms`);
}
/**
* True if an `rclone` executable is resolvable on PATH. restic's rclone backend
* shells out to it, so without it this whole spec cannot exercise anything; we
* skip rather than fail on hosts that don't provide it (CI installs it; the nix
* dev shell provides it).
*/
async function rcloneAvailable(): Promise<boolean> {
const dirs = (process.env.PATH ?? '').split(path.delimiter).filter(Boolean);
for (const dir of dirs) {
try {
await fs.access(path.join(dir, 'rclone'), fsConstants.X_OK);
return true;
} catch {
// keep looking
}
}
return false;
}
test.describe('rclone-backed repo', () => {
test('adds an rclone repo through the UI, backs up, and is usable end-to-end', async ({
page,
backrest,
}) => {
test.skip(!(await rcloneAvailable()), 'rclone not found on PATH');
// rclone init + test + backup each spawn a `rclone serve restic` process;
// give the whole flow a generous budget on a possibly-loaded machine.
test.setTimeout(420_000);
@@ -85,9 +107,10 @@ test.describe('rclone-backed repo', () => {
await dialog.getByTestId('add-repo-name').fill(REPO_NAME);
await dialog.getByTestId('add-repo-uri').fill(uri);
// The URI field is a combobox that opens a suggestions popover on input;
// close it so it doesn't sit on top of the fields/buttons below it.
await page.keyboard.press('Escape');
// Dismiss the URI autocomplete popover by refocusing the name field. Do
// NOT press Escape: when the popover has no suggestions and never opened,
// Escape closes the whole dialog instead.
await dialog.getByTestId('add-repo-name').click();
await dialog.getByTestId('add-repo-password').fill(PASSWORD);
// --- Test Configuration against the rclone URI, before submitting -----
@@ -97,9 +120,31 @@ test.describe('rclone-backed repo', () => {
// at this rclone path yet. Testing spawns `rclone serve restic`, so allow
// a wide timeout.
await dialog.getByTestId('add-repo-test-config').click();
await expect(page.getByText(`Connected successfully to ${uri}`)).toBeVisible({
timeout: 60_000,
});
// Wait for either the success banner or the error banner ("Check error:
// {message}") so a failing check reports the backend message rather than an
// opaque timeout. Each waitFor settles to 'pending' on timeout so the
// race loser can't raise an unhandled rejection.
const successBanner = page.getByText(`Connected successfully to ${uri}`);
const errorBanner = page.getByText('Check error:');
const outcome = await Promise.race([
successBanner
.waitFor({ state: 'visible', timeout: 60_000 })
.then(() => 'success')
.catch(() => 'pending'),
errorBanner
.waitFor({ state: 'visible', timeout: 60_000 })
.then(() => 'error')
.catch(() => 'pending'),
]);
if (outcome === 'error') {
// The error toast auto-dismisses after ~5s, so read it immediately.
const message = (await errorBanner.textContent())?.trim();
throw new Error(`Test Configuration reported a failure: ${message}`);
}
await expect(successBanner).toBeVisible();
// Testing configuration must not have created the repo yet.
await expect(page.getByTestId(`sidebar-item-repo-${REPO_NAME}`)).toHaveCount(0);
+8 -4
View File
@@ -28,12 +28,16 @@ export const Toaster = () => {
// @ts-ignore
<Toast.Indicator />
)}
<Stack gap="1" flex="1" maxWidth="100%">
{/* @ts-ignore */}
{toast.title && <Toast.Title>{toast.title}</Toast.Title>}
<Stack gap="1" flex="1" maxWidth="100%" userSelect="text">
{toast.title && (
// @ts-ignore
<Toast.Title userSelect="text">{toast.title}</Toast.Title>
)}
{toast.description && (
// @ts-ignore
<Toast.Description>{toast.description}</Toast.Description>
<Toast.Description userSelect="text">
{toast.description}
</Toast.Description>
)}
</Stack>
{toast.action && (
+3
View File
@@ -11,6 +11,9 @@ export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
// userEvent-driven component tests run 3-5s each and tip past the default
// 5s timeout under CI load; give headroom without masking real hangs.
testTimeout: 20_000,
setupFiles: ['./src/test/setup.tsx'],
include: ['src/**/*.test.{ts,tsx}'],
restoreMocks: true,