mirror of
https://github.com/garethgeorge/backrest.git
synced 2026-09-23 00:15:45 +00:00
chore: fix github actions runner for e2e tests covering rclone
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user