diff --git a/app/routes/__root.tsx b/app/routes/__root.tsx index aa23fac4..2f001b50 100644 --- a/app/routes/__root.tsx +++ b/app/routes/__root.tsx @@ -4,6 +4,7 @@ import { apiClientMiddleware } from "~/middleware/api-client"; import type { QueryClient } from "@tanstack/react-query"; import { Toaster } from "~/client/components/ui/sonner"; import { useServerEvents } from "~/client/hooks/use-server-events"; +import { useEffect } from "react"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({ server: { @@ -34,6 +35,12 @@ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()( function RootLayout() { useServerEvents(); + useEffect(() => { + document.body.setAttribute("data-app-ready", "true"); + return () => { + document.body.removeAttribute("data-app-ready"); + }; + }, []); return ( diff --git a/e2e/0001-auth.setup.ts b/e2e/0001-auth.setup.ts index 09088857..caf679e3 100644 --- a/e2e/0001-auth.setup.ts +++ b/e2e/0001-auth.setup.ts @@ -2,6 +2,7 @@ import fs from "fs"; import { test, expect } from "@playwright/test"; import { resetDatabase } from "./helpers/db"; import path from "node:path"; +import { gotoAndWaitForAppReady } from "./helpers/page"; const authFile = path.join(process.cwd(), "./playwright/.auth/user.json"); @@ -15,7 +16,7 @@ test.beforeAll(async () => { }); test("should redirect to onboarding", async ({ page }) => { - await page.goto("/"); + await gotoAndWaitForAppReady(page, "/"); await page.waitForURL(/onboarding/); @@ -23,7 +24,7 @@ test("should redirect to onboarding", async ({ page }) => { }); test("user can register a new account", async ({ page }) => { - await page.goto("/onboarding"); + await gotoAndWaitForAppReady(page, "/onboarding"); await page.getByRole("textbox", { name: "Email" }).click(); await page.getByRole("textbox", { name: "Email" }).fill("test@test.com"); @@ -39,7 +40,7 @@ test("user can register a new account", async ({ page }) => { }); test("user can download recovery key", async ({ page }) => { - await page.goto("/login"); + await gotoAndWaitForAppReady(page, "/login"); await page.getByRole("textbox", { name: "Username" }).fill("test"); await page.getByRole("textbox", { name: "Password" }).fill("password"); @@ -69,7 +70,7 @@ test("user can download recovery key", async ({ page }) => { }); test("can't create another admin user after initial setup", async ({ page }) => { - await page.goto("/onboarding"); + await gotoAndWaitForAppReady(page, "/onboarding"); await page.getByRole("textbox", { name: "Email" }).click(); await page.getByRole("textbox", { name: "Email" }).fill("test@test.com"); @@ -85,7 +86,7 @@ test("can't create another admin user after initial setup", async ({ page }) => }); test("can login after initial setup", async ({ page }) => { - await page.goto("/login"); + await gotoAndWaitForAppReady(page, "/login"); await page.getByRole("textbox", { name: "Username" }).fill("test"); await page.getByRole("textbox", { name: "Password" }).fill("password"); diff --git a/e2e/0002-backup-restore.spec.ts b/e2e/0002-backup-restore.spec.ts index 339df26c..0106d7ef 100644 --- a/e2e/0002-backup-restore.spec.ts +++ b/e2e/0002-backup-restore.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "@playwright/test"; import path from "node:path"; import fs from "node:fs"; +import { gotoAndWaitForAppReady } from "./helpers/page"; test.describe.configure({ mode: "serial" }); @@ -16,7 +17,7 @@ test.beforeAll(() => { }); test("can backup & restore a file", async ({ page }) => { - await page.goto("/"); + await gotoAndWaitForAppReady(page, "/"); await expect(page).toHaveURL("/volumes"); // 0. Create a test file in /test-data @@ -75,17 +76,24 @@ test("can backup & restore a file", async ({ page }) => { }); test("deleting a volume cascades and removes its backup schedule", async ({ page }) => { - await page.goto("/backups"); + await gotoAndWaitForAppReady(page, "/backups"); await page.getByText("Test Backup", { exact: true }).first().click(); - await expect(page.getByRole("link", { name: "Test Volume" })).toBeVisible(); - await page.getByRole("link", { name: "Test Volume" }).click(); + const volumeLink = page.locator("main").getByRole("link", { name: "Test Volume", exact: true }).first(); + await expect(volumeLink).toBeVisible(); + await volumeLink.click(); + await expect(page).toHaveURL(/\/volumes\/[^/?#]+/); + await expect(page.getByText("Volume Configuration", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Delete" })).toBeVisible(); - await page.getByRole("button", { name: "Delete" }).click(); + await expect(async () => { + await page.getByRole("button", { name: "Delete" }).click(); + await expect(page.getByRole("heading", { name: "Delete volume?" })).toBeVisible(); + }).toPass({ timeout: 10000 }); await expect(page.getByText("All backup schedules associated with this volume will also be removed.")).toBeVisible(); await page.getByRole("button", { name: "Delete volume" }).click(); await expect(page.getByText("Volume deleted successfully")).toBeVisible(); - await page.goto("/backups"); + await gotoAndWaitForAppReady(page, "/backups"); await expect(page.getByText("Test Backup", { exact: true })).toHaveCount(0); }); diff --git a/e2e/helpers/page.ts b/e2e/helpers/page.ts new file mode 100644 index 00000000..95e048d3 --- /dev/null +++ b/e2e/helpers/page.ts @@ -0,0 +1,10 @@ +import type { Page } from "@playwright/test"; + +export const waitForAppReady = async (page: Page) => { + await page.waitForFunction(() => document.body?.getAttribute("data-app-ready") === "true"); +}; + +export const gotoAndWaitForAppReady = async (page: Page, url: string) => { + await page.goto(url, { waitUntil: "domcontentloaded" }); + await waitForAppReady(page); +}; diff --git a/playwright.config.ts b/playwright.config.ts index af683998..0acbfcc9 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ use: { baseURL: `http://${process.env.SERVER_IP}:4096`, video: "retain-on-failure", - trace: "on-first-retry", + trace: "retain-on-failure", }, projects: [ {