Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,5 @@ Thumbs.db
# Playwright
playwright-report/
test-results/
playwright-report/
test-results/
108 changes: 108 additions & 0 deletions apps/web/e2e/product-journeys.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}
});
1 change: 1 addition & 0 deletions apps/web/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
};

Expand Down
4 changes: 3 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions apps/web/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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" },
},
}),
});
6 changes: 6 additions & 0 deletions apps/web/src/components/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDetailsElement>(null);
const commandButton = useRef<HTMLButtonElement>(null);
const commandInput = useRef<HTMLInputElement>(null);
const commandWasOpen = useRef(false);
const previousPathname = useRef(pathname);

useEffect(() => {
setHydrated(true);
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setActivityOpen(false);
Expand All @@ -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("");
Expand Down Expand Up @@ -130,6 +135,7 @@ export function AppShell({ mode, children }: AppShellProps) {
<button
ref={commandButton}
className="command-button"
disabled={!hydrated}
type="button"
aria-label="Open command palette"
aria-expanded={commandOpen}
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/intent-room-actions.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
"use client";

import { useState } from "react";
import { useEffect, useState } from "react";

export function IntentRoomActions({ intentId }: { readonly intentId: string }) {
const [message, setMessage] = useState("");
const [hydrated, setHydrated] = useState(false);
useEffect(() => setHydrated(true), []);
return (
<div className="page-actions" aria-label="Intent actions">
<button
className="primary-action"
disabled={!hydrated}
type="button"
onClick={() => setMessage(`Approval recorded for ${intentId}`)}
>
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/components/intent-wizard-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,23 @@ import { useEffect, useState } from "react";
export function IntentWizardForm() {
const [amount, setAmount] = useState("");
const [expiry, setExpiry] = useState("24");
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
const saved = window.localStorage.getItem("synesis.intent.draft");
if (saved) {
const draft = JSON.parse(saved) as { amount?: string; expiry?: string };
setAmount(draft.amount ?? "");
setExpiry(draft.expiry ?? "24");
}
setHydrated(true);
}, []);
useEffect(() => {
if (!hydrated) return;
window.localStorage.setItem(
"synesis.intent.draft",
JSON.stringify({ amount, expiry }),
);
}, [amount, expiry]);
}, [amount, expiry, hydrated]);
const valid =
/^\d+$/.test(amount) &&
Number(amount) > 0 &&
Expand Down Expand Up @@ -53,6 +56,7 @@ export function IntentWizardForm() {
</span>
<input
aria-label="Amount"
disabled={!hydrated}
inputMode="numeric"
value={amount}
onChange={(event) =>
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from "next/server";
export const SESSION_COOKIE = "__Host-synesis_session";

export function proxy(request: NextRequest): NextResponse {
if (process.env.SYNESIS_E2E === "1") return NextResponse.next();
if (request.cookies.has(SESSION_COOKIE)) return NextResponse.next();

const loginUrl = new URL("/login", request.url);
Expand Down
Loading
Loading