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..d520aaaed --- /dev/null +++ b/frontend/e2e/ask-agent.spec.ts @@ -0,0 +1,68 @@ +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.locator(".language-switcher select").selectOption("en"); + 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"); + // Fail loudly (not silently skip) if the answer stops citing a + // multi-post lineage -- the whole point of this test. + await expect(lineage).not.toHaveCount(0); + await expect(lineage.first()).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:/); + // Fail loudly (not silently skip) if the answer stops citing image + // evidence -- the whole point of this test. + await expect(imageEvidence).not.toHaveCount(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/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}