From 38e0e6b668985d30ce715e4580ec6d4b26a5c176 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 17:08:03 +0900 Subject: [PATCH 1/5] test(e2e): add a Playwright harness for the Ask Agent capabilities No Playwright config existed despite the package already being a devDependency. Add playwright.config.ts (points at the running docker-compose stack, not a Playwright-managed dev server -- the app needs Postgres/Keycloak/Valkey/orchestrator alongside it), a real Keycloak-login helper (drives the actual OIDC redirect form with the synthetic demo.analyst credentials, not a token injected into storage), a validated smoke spec, and ask-agent.spec.ts covering all four Ask Agent capabilities (relative-time retrieval #415, multi-lineage graphs #418, image citation #419, Layer Popup #420). The login flow is verified passing against a live stack right now. ask-agent.spec.ts needs #415/#418/#419/#420 merged and the images rebuilt from main before it can pass -- verified during development that the currently-running ad-hoc stack is built from an unrelated, more advanced branch (its own conversation-history UI), not main, so it cannot validate this spec; that requires a proper CI/deployment rebuild, out of this checkpoint's scope. Part of the Ask Agent temporal/lineage/evidence goal (checkpoint 5 of 6 -- e2e harness). --- frontend/.gitignore | 4 +++ frontend/e2e/ask-agent.spec.ts | 65 ++++++++++++++++++++++++++++++++++ frontend/e2e/smoke.spec.ts | 7 ++++ frontend/e2e/support/auth.ts | 27 ++++++++++++++ frontend/package.json | 2 ++ frontend/playwright.config.ts | 27 ++++++++++++++ frontend/pnpm-lock.yaml | 12 +++++++ frontend/tsconfig.e2e.json | 21 +++++++++++ frontend/tsconfig.json | 3 +- 9 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 frontend/e2e/ask-agent.spec.ts create mode 100644 frontend/e2e/smoke.spec.ts create mode 100644 frontend/e2e/support/auth.ts create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/tsconfig.e2e.json diff --git a/frontend/.gitignore b/frontend/.gitignore index 87b58f06f..e51f0c410 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -11,6 +11,10 @@ node_modules dist dist-ssr storybook-static +test-results +playwright-report +blob-report +playwright/.cache *.local # Editor directories and files diff --git a/frontend/e2e/ask-agent.spec.ts b/frontend/e2e/ask-agent.spec.ts new file mode 100644 index 000000000..4c960c6f9 --- /dev/null +++ b/frontend/e2e/ask-agent.spec.ts @@ -0,0 +1,65 @@ +import { expect, test } from "@playwright/test"; +import { loginAsDemoAnalyst } from "./support/auth.ts"; + +/** + * Exercises the four Ask Agent capabilities end to end: relative-time-scoped + * retrieval (#415), git-branch-style multi-lineage rendering (#418), image + * citation (#419), and the evidence Layer Popup (#420). + * + * Requires all four PRs merged to `main` and the backend/frontend images + * rebuilt from it -- an ad-hoc `docker compose` stack still running an + * older or unrelated branch will not satisfy these selectors (verified: the + * stack running during this checkpoint's development was built from a + * different, more advanced branch with its own conversation-history UI, not + * `main`). `smoke.spec.ts`'s login flow is the one assertion here proven to + * pass against arbitrary deployments, since the Keycloak-hosted login form + * is shared across every branch. + */ +test.beforeEach(async ({ page }) => { + await loginAsDemoAnalyst(page); + await page.getByRole("button", { name: "Ask Agent" }).click(); +}); + +test("answers a relative-time-scoped question and cites at least one post", async ({ page }) => { + await page.getByRole("textbox", { name: "Ask a question" }).fill("어제 무슨 일이 있었나요?"); + await page.getByRole("button", { name: "Ask", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Answer" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Cited posts" })).toBeVisible({ timeout: 15000 }); +}); + +test("renders a cited lineage thread as a git-branch-style graph", async ({ page }) => { + await page.getByRole("textbox", { name: "Ask a question" }).fill("What happened between these events?"); + await page.getByRole("button", { name: "Ask", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Cited posts" })).toBeVisible({ timeout: 15000 }); + const lineage = page.getByLabel("Reconstructed lineage"); + if ((await lineage.count()) > 0) { + await expect(lineage).toBeVisible(); + await expect(page.getByRole("img", { name: /lineage$/ }).first()).toBeVisible(); + } +}); + +test("cites persisted image evidence when a cited post has an embedded image", async ({ page }) => { + await page.getByRole("textbox", { name: "Ask a question" }).fill("Which project?"); + await page.getByRole("button", { name: "Ask", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Cited posts" })).toBeVisible({ timeout: 15000 }); + const imageEvidence = page.getByText(/^Image evidence:/); + if ((await imageEvidence.count()) > 0) { + await expect(imageEvidence.first()).toBeVisible(); + } +}); + +test("opens cited-post evidence in a Layer Popup without leaving the answer", async ({ page }) => { + await page.getByRole("textbox", { name: "Ask a question" }).fill("Which project?"); + await page.getByRole("button", { name: "Ask", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Cited posts" })).toBeVisible({ timeout: 15000 }); + + const viewEvidence = page.getByRole("button", { name: "View evidence" }).first(); + await viewEvidence.click(); + + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + await page.getByRole("button", { name: "Close evidence panel" }).click(); + await expect(dialog).not.toBeVisible(); + // The answer is still on screen -- the layer never navigated away. + await expect(page.getByRole("heading", { name: "Cited posts" })).toBeVisible(); +}); diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts new file mode 100644 index 000000000..af625adbb --- /dev/null +++ b/frontend/e2e/smoke.spec.ts @@ -0,0 +1,7 @@ +import { expect, test } from "@playwright/test"; +import { loginAsDemoAnalyst } from "./support/auth.ts"; + +test("logs in and reaches an authenticated destination", async ({ page }) => { + await loginAsDemoAnalyst(page); + await expect(page.getByRole("button", { name: "Ask Agent" })).toBeVisible(); +}); diff --git a/frontend/e2e/support/auth.ts b/frontend/e2e/support/auth.ts new file mode 100644 index 000000000..5f3740ade --- /dev/null +++ b/frontend/e2e/support/auth.ts @@ -0,0 +1,27 @@ +import type { Page } from "@playwright/test"; + +/** + * Synthetic demo credentials seeded by `make seed` -- never a real account. + * See `backend/tests/test_api.py`'s `_fetch_demo_analyst_token` for the + * same login this drives through the real Keycloak realm. + */ +const DEMO_USERNAME = "demo.analyst"; +const DEMO_PASSWORD = "lineageweave-demo-only"; + +/** + * Logs in through the real Keycloak-hosted login form (OIDC redirect, + * not a token injected into storage) so the e2e suite exercises the same + * authorization-code flow a reader actually goes through. + * + * Next action: call this once per test before interacting with any + * authenticated destination. + */ +export async function loginAsDemoAnalyst(page: Page): Promise { + await page.goto("/"); + await page.getByRole("button", { name: "Log in" }).click(); + await page.waitForURL(/\/realms\/lineageweave-demo\/protocol\/openid-connect\/auth/); + await page.getByLabel("Username or email").fill(DEMO_USERNAME); + await page.getByLabel("Password", { exact: true }).fill(DEMO_PASSWORD); + await page.getByRole("button", { name: "Sign In" }).click(); + await page.waitForURL((url) => !url.pathname.includes("/realms/")); +} diff --git a/frontend/package.json b/frontend/package.json index e2e996bbe..da43053c0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "lint": "oxlint", "preview": "vite preview", "test": "vitest run", + "e2e": "playwright test", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build" }, @@ -19,6 +20,7 @@ "react-oidc-context": "^3.3.1" }, "devDependencies": { + "@playwright/test": "^1.62.1", "@storybook/react-vite": "^10.5.8", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.2", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 000000000..a3fb286f5 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * Runs against the already-running docker-compose stack (`make up`), not a + * dev-server Playwright starts itself -- the app needs Postgres, Keycloak, + * Valkey, and the orchestrator alongside it, which `webServer` can't provide. + * Point `LINEAGEWEAVE_E2E_BASE_URL` at a different origin if the compose + * port mapping changes. + */ +export default defineConfig({ + testDir: "./e2e", + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: [["list"]], + use: { + baseURL: process.env.LINEAGEWEAVE_E2E_BASE_URL ?? "http://localhost:15173", + trace: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 485f53a80..632b2c205 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: specifier: ^3.3.1 version: 3.3.1(oidc-client-ts@3.5.0)(react@19.2.8) devDependencies: + '@playwright/test': + specifier: ^1.62.1 + version: 1.62.1 '@storybook/react-vite': specifier: ^10.5.8 version: 10.5.8(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.8(@types/react@19.2.18)(react@19.2.8))(typescript@6.0.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)) @@ -740,6 +743,11 @@ packages: cpu: [x64] os: [win32] + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + '@rolldown/binding-android-arm64@1.2.4': resolution: {integrity: sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2344,6 +2352,10 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.78.0': optional: true + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + '@rolldown/binding-android-arm64@1.2.4': optional: true diff --git a/frontend/tsconfig.e2e.json b/frontend/tsconfig.e2e.json new file mode 100644 index 000000000..d7828a06c --- /dev/null +++ b/frontend/tsconfig.e2e.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.e2e.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["e2e", "playwright.config.ts"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 1ffef600d..a999c8a65 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -2,6 +2,7 @@ "files": [], "references": [ { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.e2e.json" } ] } From 11a60b370d7b5783733febb593e8f91678cc403d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 18:18:22 +0900 Subject: [PATCH 2/5] fix(frontend): repair the inherited login/admin-panel build break Two TypeScript build errors on main (blocking every open PR's "Frontend lint, test, build" check, including this repo's own review bot's ability to approve them): - App.tsx imported rememberOidcReturnUrl/returnUrlFromLocation from oidcReturnUrl.ts but never called them -- the login button built its own unsanitized returnUrl inline instead of using the safe helper (oidcReturnUrl.ts's isSafeReturnUrl guard against an open-redirect- shaped value) or persisting it as the sessionStorage/localStorage fallback restoreOidcReturnUrl (already wired up on the callback side in main.tsx) reads when the OIDC state round-trip drops it. - The unauthenticated login screen unconditionally rendered when destination === "admin" -- accessToken is string | undefined here (always undefined while unauthenticated), a real type error, and the render was unreachable through normal navigation (destination only changes via the authenticated nav) -- dead code, removed. uv run --frozen python -m pytest -q: 753 passed, 17 skipped. pnpm run test: 140 passed. pnpm run lint / build: clean. --- frontend/src/App.test.tsx | 3 +++ frontend/src/App.tsx | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 7462abd2c..70eb27590 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -41,6 +41,9 @@ describe("App, unauthenticated", () => { state: expect.objectContaining({ returnUrl: expect.stringMatching(/^\//) }), }), ); + // Persisted as a fallback in case the OIDC state round-trip is dropped + // (see oidcReturnUrl.ts's restoreOidcReturnUrl, consumed in main.tsx). + expect(window.sessionStorage.getItem("lineageweave.oidc.returnUrl")).toMatch(/^\//); }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6fba0dd41..1b5b351ab 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4610,7 +4610,8 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean
- {destination === "admin" ? : null}