diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc3d750..f7bddd0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,17 @@ jobs: - run: pnpm --filter @synesis/database db:rollback - run: pnpm --filter @synesis/database db:migrate - run: pnpm test + - run: pnpm --filter @synesis/web exec playwright install --with-deps chromium + - run: pnpm --filter @synesis/web e2e + - name: Upload Playwright failure artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-artifacts + path: | + apps/web/playwright-report + apps/web/test-results + if-no-files-found: ignore - run: pnpm build python: diff --git a/.gitignore b/.gitignore index 00cb050..bdf5d6b 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,5 @@ Thumbs.db # Playwright playwright-report/ test-results/ +playwright-report/ +test-results/ diff --git a/apps/web/e2e/product-journeys.spec.ts b/apps/web/e2e/product-journeys.spec.ts new file mode 100644 index 0000000..1d51973 --- /dev/null +++ b/apps/web/e2e/product-journeys.spec.ts @@ -0,0 +1,108 @@ +import { expect, test, type Page } from "@playwright/test"; + +const primaryRoutes = [ + "/app", + "/app/intents", + "/app/intents/new", + "/app/mechs", + "/app/policies", + "/app/executions", + "/app/treasury", + "/app/proofs", + "/app/settings/integrations", + "/app/settings/security", +] as const; + +const expectAccessiblePageStructure = async (page: Page) => { + await expect(page.locator("main")).toBeVisible(); + await expect(page.locator("h1")).toHaveCount(1); + await expect( + page.getByRole("navigation", { name: "Primary navigation" }), + ).toBeVisible(); + const allButtonsNamed = await page + .locator("button") + .evaluateAll((buttons) => + buttons.every((button) => + Boolean( + button.getAttribute("aria-label") ?? button.textContent?.trim(), + ), + ), + ); + expect(allButtonsNamed).toBe(true); + const idsAreUnique = await page.locator("[id]").evaluateAll((nodes) => { + const ids = nodes.map((node) => node.id); + return new Set(ids).size === ids.length; + }); + expect(idsAreUnique).toBe(true); +}; + +test.describe("Synesis connected product journeys", () => { + test("the command center connects navigation, the intent draft, and an intent room", async ({ + page, + }) => { + await page.goto("/app"); + await expect( + page.getByRole("heading", { name: /Value moves only/i }), + ).toBeVisible(); + await page.getByRole("link", { name: /Create intent/i }).click(); + await expect(page).toHaveURL(/\/app\/intents\/new$/u); + + const amount = page.getByLabel("Amount"); + await expect(amount).toBeEnabled(); + await amount.fill("1000000"); + await expect(page.getByText("VALIDATED", { exact: true })).toBeVisible(); + await page.reload(); + await expect(page.getByLabel("Amount")).toHaveValue("1000000"); + + await page.getByRole("link", { name: "Intents" }).first().click(); + await expect(page).toHaveURL(/\/app\/intents$/u); + await page.getByRole("link", { name: "USDC yield rebalance" }).click(); + await expect(page).toHaveURL(/\/app\/intents\/SYN-1042$/u); + await expect( + page.getByRole("heading", { name: /USDC yield rebalance/i }), + ).toBeVisible(); + }); + + test("refreshes and reconnects a long-running intent without duplicating an action", async ({ + page, + }) => { + await page.goto("/app/intents/SYN-1042"); + await page.reload(); + await expect(page.getByText(/AWAITING/i).first()).toBeVisible(); + + const approve = page.getByRole("button", { name: /Approve execution/i }); + await expect(approve).toBeEnabled(); + await approve.click(); + await approve.click(); + await expect(page.getByText("Approval recorded for SYN-1042")).toHaveCount( + 1, + ); + }); + + test("keyboard command navigation reaches a connected product surface", async ({ + page, + }) => { + await page.goto("/app"); + await expect( + page.getByRole("button", { name: "Open command palette" }), + ).toBeEnabled(); + await page.keyboard.press("Control+k"); + await expect( + page.getByRole("dialog", { name: /Jump to a Synesis surface/i }), + ).toBeVisible(); + await page + .getByRole("dialog", { name: /Jump to a Synesis surface/i }) + .getByRole("link", { name: "Executions" }) + .click(); + await expect(page).toHaveURL(/\/app\/executions$/u); + }); + + for (const route of primaryRoutes) { + test(`meets baseline accessibility structure on ${route}`, async ({ + page, + }) => { + await page.goto(route); + await expectAccessiblePageStructure(page); + }); + } +}); diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 775f909..61bca8b 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -3,6 +3,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { poweredByHeader: false, reactStrictMode: true, + allowedDevOrigins: ["127.0.0.1"], transpilePackages: ["@synesis/domain", "@synesis/ui"], }; diff --git a/apps/web/package.json b/apps/web/package.json index 47a8a83..8a3c0fb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,8 +5,9 @@ "scripts": { "build": "next build", "dev": "next dev --port 3000", + "e2e": "playwright test", "lint": "eslint src next.config.ts", - "test": "vitest run --passWithNoTests", + "test": "vitest run --passWithNoTests --exclude=e2e/**", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -17,6 +18,7 @@ "react-dom": "^19.2.0" }, "devDependencies": { + "@playwright/test": "1.58.2", "@types/node": "^24.3.0", "@types/react": "^19.1.12", "@types/react-dom": "^19.1.9", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 0000000..afec928 --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? [["html", { open: "never" }], ["list"]] : "list", + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:3000", + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + ...(process.env.PLAYWRIGHT_BASE_URL + ? {} + : { + webServer: { + command: "corepack pnpm dev", + url: "http://127.0.0.1:3000", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + env: { SYNESIS_E2E: "1" }, + }, + }), +}); diff --git a/apps/web/src/components/app-shell.tsx b/apps/web/src/components/app-shell.tsx index a2c3147..c14619e 100644 --- a/apps/web/src/components/app-shell.tsx +++ b/apps/web/src/components/app-shell.tsx @@ -57,13 +57,16 @@ export function AppShell({ mode, children }: AppShellProps) { const [activityOpen, setActivityOpen] = useState(false); const [commandOpen, setCommandOpen] = useState(false); const [commandQuery, setCommandQuery] = useState(""); + const [hydrated, setHydrated] = useState(false); const pathname = usePathname(); const mobileMenu = useRef(null); const commandButton = useRef(null); const commandInput = useRef(null); const commandWasOpen = useRef(false); + const previousPathname = useRef(pathname); useEffect(() => { + setHydrated(true); const closeOnEscape = (event: KeyboardEvent) => { if (event.key === "Escape") { setActivityOpen(false); @@ -80,6 +83,8 @@ export function AppShell({ mode, children }: AppShellProps) { }, []); useEffect(() => { + if (previousPathname.current === pathname) return; + previousPathname.current = pathname; mobileMenu.current?.removeAttribute("open"); setCommandOpen(false); setCommandQuery(""); @@ -130,6 +135,7 @@ export function AppShell({ mode, children }: AppShellProps) {