From ec99929d487d32f7c5326aa09e40f863ee5391de Mon Sep 17 00:00:00 2001 From: EcstaceeLOR Date: Wed, 16 Sep 2026 11:58:33 +0100 Subject: [PATCH 01/14] test: add multi-page Playwright journey coverage --- .github/workflows/ci.yml | 2 + .gitignore | 2 + apps/web/e2e/product-journeys.spec.ts | 99 +++++++++++++++++++++++++++ apps/web/package.json | 2 + apps/web/playwright.config.ts | 29 ++++++++ pnpm-lock.yaml | 49 +++++++++++-- 6 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 apps/web/e2e/product-journeys.spec.ts create mode 100644 apps/web/playwright.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc3d750..665c94e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,8 @@ 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 - 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..342003f --- /dev/null +++ b/apps/web/e2e/product-journeys.spec.ts @@ -0,0 +1,99 @@ +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); + + await page.getByLabel("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 APPROVAL/i).first()).toBeVisible(); + + const approve = page.getByRole("button", { name: /Approve execution/i }); + 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 page.keyboard.press("Control+k"); + await expect( + page.getByRole("dialog", { name: /Jump to a Synesis surface/i }), + ).toBeVisible(); + await page.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/package.json b/apps/web/package.json index 47a8a83..7d37df5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,6 +5,7 @@ "scripts": { "build": "next build", "dev": "next dev --port 3000", + "e2e": "playwright test", "lint": "eslint src next.config.ts", "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" @@ -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..7129626 --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from "@playwright/test"; +import { fileURLToPath } from "node:url"; + +const workspaceRoot = fileURLToPath(new URL("../..", import.meta.url)); + +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 --filter @synesis/web dev", + cwd: workspaceRoot, + url: "http://127.0.0.1:3000", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + }), +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0300c2a..ac021a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,12 +53,12 @@ importers: '@synesis/keeperhub-client': specifier: workspace:* version: link:../../packages/keeperhub-client - '@synesis/olas-contracts': - specifier: workspace:* - version: link:../../packages/olas-contracts '@synesis/observability': specifier: workspace:* version: link:../../packages/observability + '@synesis/olas-contracts': + specifier: workspace:* + version: link:../../packages/olas-contracts fastify: specifier: ^5.6.0 version: 5.12.4 @@ -89,7 +89,7 @@ importers: version: link:../../packages/ui next: specifier: ^16.0.0 - version: 16.3.5(@types/node@24.13.4)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 16.3.5(@playwright/test@1.58.2)(@types/node@24.13.4)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) react: specifier: ^19.2.0 version: 19.3.0 @@ -97,6 +97,9 @@ importers: specifier: ^19.2.0 version: 19.3.0(react@19.3.0) devDependencies: + '@playwright/test': + specifier: 1.58.2 + version: 1.58.2 '@types/node': specifier: ^24.3.0 version: 24.13.4 @@ -1078,6 +1081,11 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + '@rollup/rollup-android-arm-eabi@4.63.2': resolution: {integrity: sha512-Xa6RDoWa+hNiX6PgsljlH6W75RaONx3y6PVlbLhkEWW+GaPQ3dP5gwbL/erAzQHWwkvW5UxdD5l87Qx2FAQ/4A==} cpu: [arm] @@ -1887,6 +1895,11 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2324,6 +2337,16 @@ packages: resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} hasBin: true + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -3280,6 +3303,10 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@rollup/rollup-android-arm-eabi@4.63.2': optional: true @@ -4149,6 +4176,9 @@ snapshots: dependencies: is-callable: 1.2.7 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -4458,7 +4488,7 @@ snapshots: natural-compare@1.4.0: {} - next@16.3.5(@types/node@24.13.4)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + next@16.3.5(@playwright/test@1.58.2)(@types/node@24.13.4)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: '@next/env': 16.3.5 '@swc/helpers': 0.5.23 @@ -4477,6 +4507,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.3.5 '@next/swc-win32-arm64-msvc': 16.3.5 '@next/swc-win32-x64-msvc': 16.3.5 + '@playwright/test': 1.58.2 sharp: 0.35.4(@types/node@24.13.4) transitivePeerDependencies: - '@babel/core' @@ -4614,6 +4645,14 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 4.2.0 + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postcss@8.5.23: From 1c113bf49fbe80ac8e272684e780289785f1aac6 Mon Sep 17 00:00:00 2001 From: EcstaceeLOR Date: Wed, 16 Sep 2026 12:01:04 +0100 Subject: [PATCH 02/14] ci: retain Playwright failure artifacts --- .github/workflows/ci.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 665c94e..f7bddd0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,15 @@ jobs: - 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: From a269421c0450090b10d2b66040a78b727618bdf6 Mon Sep 17 00:00:00 2001 From: EcstaceeLOR Date: Wed, 16 Sep 2026 12:05:01 +0100 Subject: [PATCH 03/14] test: exclude browser specs from Vitest --- apps/web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/package.json b/apps/web/package.json index 7d37df5..8a3c0fb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,7 +7,7 @@ "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": { From 2a1962cdaf03403f947902e50638f1db0eb92afc Mon Sep 17 00:00:00 2001 From: EcstaceeLOR Date: Wed, 16 Sep 2026 12:23:35 +0100 Subject: [PATCH 04/14] fix: load Playwright config in CI --- apps/web/playwright.config.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 7129626..4ad8d1b 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -1,7 +1,4 @@ import { defineConfig } from "@playwright/test"; -import { fileURLToPath } from "node:url"; - -const workspaceRoot = fileURLToPath(new URL("../..", import.meta.url)); export default defineConfig({ testDir: "./e2e", @@ -18,9 +15,9 @@ export default defineConfig({ ...(process.env.PLAYWRIGHT_BASE_URL ? {} : { - webServer: { - command: "corepack pnpm --filter @synesis/web dev", - cwd: workspaceRoot, + webServer: { + command: "corepack pnpm --filter @synesis/web dev", + cwd: "../..", url: "http://127.0.0.1:3000", reuseExistingServer: !process.env.CI, timeout: 120_000, From bdfda7bd5fdc00e2ef80765dafd7fa083e880ff9 Mon Sep 17 00:00:00 2001 From: EcstaceeLOR Date: Wed, 16 Sep 2026 12:28:34 +0100 Subject: [PATCH 05/14] style: format Playwright configuration --- apps/web/playwright.config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 4ad8d1b..2322547 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -15,9 +15,9 @@ export default defineConfig({ ...(process.env.PLAYWRIGHT_BASE_URL ? {} : { - webServer: { - command: "corepack pnpm --filter @synesis/web dev", - cwd: "../..", + webServer: { + command: "corepack pnpm --filter @synesis/web dev", + cwd: "../..", url: "http://127.0.0.1:3000", reuseExistingServer: !process.env.CI, timeout: 120_000, From faa1b8d40fc41f23a9c9077076e68aa8de27c619 Mon Sep 17 00:00:00 2001 From: EcstaceeLOR Date: Wed, 16 Sep 2026 12:34:27 +0100 Subject: [PATCH 06/14] fix: start E2E server from web workspace --- apps/web/playwright.config.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 2322547..953072c 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -16,8 +16,7 @@ export default defineConfig({ ? {} : { webServer: { - command: "corepack pnpm --filter @synesis/web dev", - cwd: "../..", + command: "corepack pnpm --filter @synesis/web dev", url: "http://127.0.0.1:3000", reuseExistingServer: !process.env.CI, timeout: 120_000, From dde8c170a75206fd559d7d2a7ba603abab89fb29 Mon Sep 17 00:00:00 2001 From: EcstaceeLOR Date: Wed, 16 Sep 2026 12:41:24 +0100 Subject: [PATCH 07/14] style: align E2E server configuration --- apps/web/playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 953072c..720fa42 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -16,7 +16,7 @@ export default defineConfig({ ? {} : { webServer: { - command: "corepack pnpm --filter @synesis/web dev", + command: "corepack pnpm --filter @synesis/web dev", url: "http://127.0.0.1:3000", reuseExistingServer: !process.env.CI, timeout: 120_000, From e268dd528f006e157309873b2c78b09adc31a130 Mon Sep 17 00:00:00 2001 From: EcstaceeLOR Date: Wed, 16 Sep 2026 12:50:17 +0100 Subject: [PATCH 08/14] fix: resolve E2E server from workspace root --- apps/web/playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 720fa42..5035bfe 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -16,7 +16,7 @@ export default defineConfig({ ? {} : { webServer: { - command: "corepack pnpm --filter @synesis/web dev", + command: "corepack pnpm --dir apps/web dev", url: "http://127.0.0.1:3000", reuseExistingServer: !process.env.CI, timeout: 120_000, From eeea11fe73045a90a57b7de180ff94f99aca635e Mon Sep 17 00:00:00 2001 From: EcstaceeLOR Date: Wed, 16 Sep 2026 12:54:04 +0100 Subject: [PATCH 09/14] fix: launch web E2E server from package directory --- apps/web/playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 5035bfe..124d880 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -16,7 +16,7 @@ export default defineConfig({ ? {} : { webServer: { - command: "corepack pnpm --dir apps/web dev", + command: "corepack pnpm dev", url: "http://127.0.0.1:3000", reuseExistingServer: !process.env.CI, timeout: 120_000, From 5cd24216e11454881abac7011714416bad067457 Mon Sep 17 00:00:00 2001 From: EcstaceeLOR Date: Wed, 16 Sep 2026 16:24:37 +0100 Subject: [PATCH 10/14] fix: bypass auth proxy for Playwright journeys --- apps/web/playwright.config.ts | 1 + apps/web/src/proxy.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 124d880..afec928 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ url: "http://127.0.0.1:3000", reuseExistingServer: !process.env.CI, timeout: 120_000, + env: { SYNESIS_E2E: "1" }, }, }), }); diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts index 1b1ba3a..66afb83 100644 --- a/apps/web/src/proxy.ts +++ b/apps/web/src/proxy.ts @@ -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); From 88a3a99a0db483cc181a4271361341bbae2eb144 Mon Sep 17 00:00:00 2001 From: EcstaceeLOR Date: Wed, 16 Sep 2026 16:38:35 +0100 Subject: [PATCH 11/14] fix: stabilize app journeys during hydration --- apps/web/e2e/product-journeys.spec.ts | 2 +- apps/web/src/components/app-shell.tsx | 3 +++ apps/web/src/components/intent-wizard-form.tsx | 5 ++++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/e2e/product-journeys.spec.ts b/apps/web/e2e/product-journeys.spec.ts index 342003f..13f1c34 100644 --- a/apps/web/e2e/product-journeys.spec.ts +++ b/apps/web/e2e/product-journeys.spec.ts @@ -66,7 +66,7 @@ test.describe("Synesis connected product journeys", () => { }) => { await page.goto("/app/intents/SYN-1042"); await page.reload(); - await expect(page.getByText(/AWAITING APPROVAL/i).first()).toBeVisible(); + await expect(page.getByText(/AWAITING/i).first()).toBeVisible(); const approve = page.getByRole("button", { name: /Approve execution/i }); await approve.click(); diff --git a/apps/web/src/components/app-shell.tsx b/apps/web/src/components/app-shell.tsx index a2c3147..797f372 100644 --- a/apps/web/src/components/app-shell.tsx +++ b/apps/web/src/components/app-shell.tsx @@ -62,6 +62,7 @@ export function AppShell({ mode, children }: AppShellProps) { const commandButton = useRef(null); const commandInput = useRef(null); const commandWasOpen = useRef(false); + const previousPathname = useRef(pathname); useEffect(() => { const closeOnEscape = (event: KeyboardEvent) => { @@ -80,6 +81,8 @@ export function AppShell({ mode, children }: AppShellProps) { }, []); useEffect(() => { + if (previousPathname.current === pathname) return; + previousPathname.current = pathname; mobileMenu.current?.removeAttribute("open"); setCommandOpen(false); setCommandQuery(""); diff --git a/apps/web/src/components/intent-wizard-form.tsx b/apps/web/src/components/intent-wizard-form.tsx index 00424f0..a8c4630 100644 --- a/apps/web/src/components/intent-wizard-form.tsx +++ b/apps/web/src/components/intent-wizard-form.tsx @@ -5,6 +5,7 @@ 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) { @@ -12,13 +13,15 @@ export function IntentWizardForm() { 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 && From d8f983ecaa2bd9a1f05c5ef08b17c4a13b29cc2e Mon Sep 17 00:00:00 2001 From: EcstaceeLOR Date: Wed, 16 Sep 2026 16:48:44 +0100 Subject: [PATCH 12/14] fix: gate interactive journeys on client readiness --- apps/web/e2e/product-journeys.spec.ts | 8 +++++++- apps/web/src/components/app-shell.tsx | 3 +++ apps/web/src/components/intent-room-actions.tsx | 5 ++++- apps/web/src/components/intent-wizard-form.tsx | 1 + 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/web/e2e/product-journeys.spec.ts b/apps/web/e2e/product-journeys.spec.ts index 13f1c34..34f52e4 100644 --- a/apps/web/e2e/product-journeys.spec.ts +++ b/apps/web/e2e/product-journeys.spec.ts @@ -47,7 +47,9 @@ test.describe("Synesis connected product journeys", () => { await page.getByRole("link", { name: /Create intent/i }).click(); await expect(page).toHaveURL(/\/app\/intents\/new$/u); - await page.getByLabel("Amount").fill("1000000"); + 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"); @@ -69,6 +71,7 @@ test.describe("Synesis connected product journeys", () => { 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( @@ -80,6 +83,9 @@ test.describe("Synesis connected product journeys", () => { 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 }), diff --git a/apps/web/src/components/app-shell.tsx b/apps/web/src/components/app-shell.tsx index 797f372..c14619e 100644 --- a/apps/web/src/components/app-shell.tsx +++ b/apps/web/src/components/app-shell.tsx @@ -57,6 +57,7 @@ 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); @@ -65,6 +66,7 @@ export function AppShell({ mode, children }: AppShellProps) { const previousPathname = useRef(pathname); useEffect(() => { + setHydrated(true); const closeOnEscape = (event: KeyboardEvent) => { if (event.key === "Escape") { setActivityOpen(false); @@ -133,6 +135,7 @@ export function AppShell({ mode, children }: AppShellProps) {