diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 215592e9..e0a81ac5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/flake.nix b/flake.nix index 8964d9b8..a3b0b9da 100644 --- a/flake.nix +++ b/flake.nix @@ -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 diff --git a/webui/e2e/specs/add-repo.spec.ts b/webui/e2e/specs/add-repo.spec.ts index 5cf1ef2b..b649d5fd 100644 --- a/webui/e2e/specs/add-repo.spec.ts +++ b/webui/e2e/specs/add-repo.spec.ts @@ -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(); diff --git a/webui/e2e/specs/error-paths.spec.ts b/webui/e2e/specs/error-paths.spec.ts index 07bc58c9..9ce13c7b 100644 --- a/webui/e2e/specs/error-paths.spec.ts +++ b/webui/e2e/specs/error-paths.spec.ts @@ -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(); diff --git a/webui/e2e/specs/rclone-repo.spec.ts b/webui/e2e/specs/rclone-repo.spec.ts index 114bcb8e..63ab09c1 100644 --- a/webui/e2e/specs/rclone-repo.spec.ts +++ b/webui/e2e/specs/rclone-repo.spec.ts @@ -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 { + 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); diff --git a/webui/src/components/ui/toaster.tsx b/webui/src/components/ui/toaster.tsx index 461e9b75..6405400f 100644 --- a/webui/src/components/ui/toaster.tsx +++ b/webui/src/components/ui/toaster.tsx @@ -28,12 +28,16 @@ export const Toaster = () => { // @ts-ignore )} - - {/* @ts-ignore */} - {toast.title && {toast.title}} + + {toast.title && ( + // @ts-ignore + {toast.title} + )} {toast.description && ( // @ts-ignore - {toast.description} + + {toast.description} + )} {toast.action && ( diff --git a/webui/vitest.config.ts b/webui/vitest.config.ts index 352beaac..2b00ed9d 100644 --- a/webui/vitest.config.ts +++ b/webui/vitest.config.ts @@ -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,