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
4 changes: 4 additions & 0 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ node_modules
dist
dist-ssr
storybook-static
test-results
playwright-report
blob-report
playwright/.cache
*.local

# Editor directories and files
Expand Down
68 changes: 68 additions & 0 deletions frontend/e2e/ask-agent.spec.ts
Original file line number Diff line number Diff line change
@@ -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("어제 무슨 일이 있었나요?");
Comment thread
seonghobae marked this conversation as resolved.
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();
Comment thread
seonghobae marked this conversation as resolved.
});

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();
});
7 changes: 7 additions & 0 deletions frontend/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
27 changes: 27 additions & 0 deletions frontend/e2e/support/auth.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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/"));
}
2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"lint": "oxlint",
"preview": "vite preview",
"test": "vitest run",
"e2e": "playwright test",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
Expand All @@ -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",
Expand Down
27 changes: 27 additions & 0 deletions frontend/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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"] },
},
],
});
12 changes: 12 additions & 0 deletions frontend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(/^\//);
});
});

Expand Down
4 changes: 2 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4610,7 +4610,8 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean
</div>
<div className="login-controls">
<button className="btn-primary" onClick={() => {
const returnUrl = window.location.pathname + window.location.search;
const returnUrl = returnUrlFromLocation();
rememberOidcReturnUrl(returnUrl);
Comment thread
seonghobae marked this conversation as resolved.
void auth.signinRedirect({ state: { returnUrl } });
}}>
{t("Log in")}
Expand All @@ -4620,7 +4621,6 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean
<small>Enterprise SSO Authentication</small>
</div>
</div>
{destination === "admin" ? <AdminPanel currentBrandName={brandName} onBrandNameChange={setBrandName} accessToken={accessToken} /> : null}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
</main>
<footer className="app-footer" role="contentinfo">
<div className="app-footer-title">
Expand Down
21 changes: 21 additions & 0 deletions frontend/tsconfig.e2e.json
Original file line number Diff line number Diff line change
@@ -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"]
}
3 changes: 2 additions & 1 deletion frontend/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.e2e.json" }
]
}
3 changes: 2 additions & 1 deletion frontend/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
/// <reference types="vitest/config" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { configDefaults } from 'vitest/config'

// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/setupTests.ts'],
exclude: [...configDefaults.exclude, 'e2e/**'],
},
})
Loading