3 Commits

Author SHA1 Message Date
Sharang Parnerkar
ece48287b9 ci: add E2E test job with Playwright and service containers
All checks were successful
CI / Format (push) Successful in 2s
CI / Clippy (push) Successful in 2m44s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / Format (pull_request) Successful in 2s
CI / Clippy (pull_request) Successful in 2m50s
CI / Security Audit (pull_request) Has been skipped
CI / Tests (pull_request) Has been skipped
CI / E2E Tests (push) Has been skipped
CI / E2E Tests (pull_request) Has been skipped
CI / Deploy (push) Has been skipped
CI / Deploy (pull_request) Has been skipped
Run Playwright browser tests on main and PRs to main after quality
checks pass. Spins up MongoDB and SearXNG as services, starts Keycloak
manually post-checkout (needs realm-export.json from repo), builds and
serves the Dioxus app, then runs the full E2E suite. Deploy now gates
on both unit and E2E tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 10:08:32 +01:00
Sharang Parnerkar
420e0555f7 test: add Playwright E2E test suite (30 tests)
Add browser-level end-to-end tests covering public pages, Keycloak
OAuth authentication flow, dashboard interactions, providers config,
developer section, organization pages, and sidebar navigation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 10:04:09 +01:00
Sharang Parnerkar
978c86cc9a test: add infrastructure logic unit tests (37 new tests)
Add Phase 2 test coverage for infrastructure modules:
- state.rs: 6 tests (defaults, serde round-trips, UserState deref/clone)
- provider_client.rs: 2 tests (ProviderMessage serde)
- llm.rs: 12 tests (FollowUpMessage serde, joined_len, parse_article_html
  extraction with article/main/role=main tags, fallback, exclusions,
  truncation, fragment skipping)
- chat.rs: 17 tests (doc_to_chat_session, doc_to_chat_message BSON
  conversion, resolve_provider_url for all providers)

Refactor: extract parse_article_html from fetch_article_text for testability
without HTTP. Refactor resolve_provider_url to accept explicit params
instead of full ServerState, avoiding need for MongoDB in tests.

Total test count: 129 (up from 92).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 09:39:09 +01:00
17 changed files with 1120 additions and 9 deletions

View File

@@ -120,13 +120,143 @@ jobs:
run: sccache --show-stats
if: always()
# ---------------------------------------------------------------------------
# Stage 2b: E2E tests (only on main / PRs to main, after quality checks)
# ---------------------------------------------------------------------------
e2e:
name: E2E Tests
runs-on: docker
needs: [fmt, clippy, audit]
if: github.ref == 'refs/heads/main' || github.event_name == 'pull_request'
container:
image: rust:1.89-bookworm
# MongoDB and SearXNG can start immediately (no repo files needed).
# Keycloak requires realm-export.json from the repo, so it is started
# manually after checkout via docker CLI.
services:
mongo:
image: mongo:latest
env:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: example
ports:
- 27017:27017
searxng:
image: searxng/searxng:latest
env:
SEARXNG_BASE_URL: http://localhost:8888
ports:
- 8888:8080
env:
KEYCLOAK_URL: http://localhost:8080
KEYCLOAK_REALM: certifai
KEYCLOAK_CLIENT_ID: certifai-dashboard
MONGODB_URI: mongodb://root:example@mongo:27017
MONGODB_DATABASE: certifai
SEARXNG_URL: http://searxng:8080
steps:
- name: Checkout
run: |
git init
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git fetch --depth=1 origin "${GITHUB_SHA}"
git checkout FETCH_HEAD
- name: Install system dependencies
run: |
apt-get update -qq
apt-get install -y -qq --no-install-recommends \
unzip curl docker.io \
libglib2.0-0 libnss3 libnspr4 libdbus-1-3 libatk1.0-0 \
libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 \
libxdamage1 libxfixes3 libxrandr2 libgbm1 libpango-1.0-0 \
libcairo2 libasound2 libatspi2.0-0 libxshmfence1
- name: Start Keycloak
run: |
docker run -d --name ci-keycloak --network host \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
-e KC_DB=dev-mem \
-e KC_HEALTH_ENABLED=true \
-v "$PWD/keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro" \
-v "$PWD/keycloak/themes/certifai:/opt/keycloak/themes/certifai:ro" \
quay.io/keycloak/keycloak:26.0 start-dev --import-realm
echo "Waiting for Keycloak..."
for i in $(seq 1 60); do
if curl -sf http://localhost:8080/realms/certifai > /dev/null 2>&1; then
echo "Keycloak is ready"
break
fi
if [ "$i" -eq 60 ]; then
echo "Keycloak failed to start within 60s"
docker logs ci-keycloak
exit 1
fi
sleep 2
done
- name: Install sccache
run: |
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \
| tar xz --strip-components=1 -C /usr/local/bin/ sccache-v0.9.1-x86_64-unknown-linux-musl/sccache
chmod +x /usr/local/bin/sccache
- name: Install dioxus-cli
run: cargo install dioxus-cli --locked
- name: Install bun
run: |
curl -fsSL https://bun.sh/install | bash
echo "$HOME/.bun/bin" >> "$GITHUB_PATH"
- name: Install Playwright
run: |
export PATH="$HOME/.bun/bin:$PATH"
bun install
bunx playwright install chromium
- name: Build app
run: dx build --release
- name: Start app and run E2E tests
run: |
export PATH="$HOME/.bun/bin:$PATH"
# Start the app in the background
dx serve --release --port 8000 &
APP_PID=$!
# Wait for the app to be ready
echo "Waiting for app to start..."
for i in $(seq 1 60); do
if curl -sf http://localhost:8000 > /dev/null 2>&1; then
echo "App is ready"
break
fi
if [ "$i" -eq 60 ]; then
echo "App failed to start within 60s"
exit 1
fi
sleep 1
done
BASE_URL=http://localhost:8000 bunx playwright test --reporter=list
kill "$APP_PID" 2>/dev/null || true
- name: Upload test report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7
- name: Cleanup Keycloak
if: always()
run: docker rm -f ci-keycloak 2>/dev/null || true
- name: Show sccache stats
run: sccache --show-stats
if: always()
# ---------------------------------------------------------------------------
# Stage 3: Deploy (only after tests pass, only on main)
# ---------------------------------------------------------------------------
deploy:
name: Deploy
runs-on: docker
needs: [test]
needs: [test, e2e]
if: github.ref == 'refs/heads/main'
container:
image: alpine:latest

5
.gitignore vendored
View File

@@ -22,3 +22,8 @@ keycloak/*
node_modules/
searxng/
# Playwright
e2e/.auth/
playwright-report/
test-results/

View File

@@ -8,6 +8,7 @@
"tailwindcss": "^4.1.18",
},
"devDependencies": {
"@playwright/test": "^1.52.0",
"@types/bun": "latest",
},
"peerDependencies": {
@@ -16,6 +17,8 @@
},
},
"packages": {
"@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="],
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
@@ -24,6 +27,12 @@
"daisyui": ["daisyui@5.5.18", "", {}, "sha512-VVzjpOitMGB6DWIBeRSapbjdOevFqyzpk9u5Um6a4tyId3JFrU5pbtF0vgjXDth76mJZbueN/j9Ok03SPrh/og=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
"playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],

24
e2e/auth.setup.ts Normal file
View File

@@ -0,0 +1,24 @@
import { test as setup, expect } from "@playwright/test";
const AUTH_FILE = "e2e/.auth/user.json";
setup("authenticate via Keycloak", async ({ page }) => {
// Navigate to a protected route to trigger the auth redirect chain:
// /dashboard -> /auth (Axum) -> Keycloak login page
await page.goto("/dashboard");
// Wait for Keycloak login form to appear
await page.waitForSelector("#username", { timeout: 15_000 });
// Fill Keycloak credentials
await page.fill("#username", process.env.TEST_USER ?? "admin@certifai.local");
await page.fill("#password", process.env.TEST_PASSWORD ?? "admin");
await page.click("#kc-login");
// Wait for redirect back to the app dashboard
await page.waitForURL("**/dashboard", { timeout: 15_000 });
await expect(page.locator(".sidebar")).toBeVisible();
// Persist authenticated state (cookies + localStorage)
await page.context().storageState({ path: AUTH_FILE });
});

72
e2e/auth.spec.ts Normal file
View File

@@ -0,0 +1,72 @@
import { test, expect } from "@playwright/test";
// These tests use a fresh browser context (no saved auth state)
test.use({ storageState: { cookies: [], origins: [] } });
test.describe("Authentication flow", () => {
test("unauthenticated visit to /dashboard redirects to Keycloak", async ({
page,
}) => {
await page.goto("/dashboard");
// Should end up on Keycloak login page
await page.waitForSelector("#username", { timeout: 15_000 });
await expect(page.locator("#kc-login")).toBeVisible();
});
test("valid credentials log in and redirect to dashboard", async ({
page,
}) => {
await page.goto("/dashboard");
await page.waitForSelector("#username", { timeout: 15_000 });
await page.fill(
"#username",
process.env.TEST_USER ?? "admin@certifai.local"
);
await page.fill("#password", process.env.TEST_PASSWORD ?? "admin");
await page.click("#kc-login");
await page.waitForURL("**/dashboard", { timeout: 15_000 });
await expect(page.locator(".dashboard-page")).toBeVisible();
});
test("dashboard shows sidebar with user info after login", async ({
page,
}) => {
await page.goto("/dashboard");
await page.waitForSelector("#username", { timeout: 15_000 });
await page.fill(
"#username",
process.env.TEST_USER ?? "admin@certifai.local"
);
await page.fill("#password", process.env.TEST_PASSWORD ?? "admin");
await page.click("#kc-login");
await page.waitForURL("**/dashboard", { timeout: 15_000 });
await expect(page.locator(".sidebar-name")).toBeVisible();
await expect(page.locator(".sidebar-email")).toBeVisible();
});
test("logout redirects away from dashboard", async ({ page }) => {
// First log in
await page.goto("/dashboard");
await page.waitForSelector("#username", { timeout: 15_000 });
await page.fill(
"#username",
process.env.TEST_USER ?? "admin@certifai.local"
);
await page.fill("#password", process.env.TEST_PASSWORD ?? "admin");
await page.click("#kc-login");
await page.waitForURL("**/dashboard", { timeout: 15_000 });
// Click logout
await page.locator('a.logout-btn, a[href="/logout"]').click();
// Should no longer be on the dashboard
await expect(page).not.toHaveURL(/\/dashboard/);
});
});

75
e2e/dashboard.spec.ts Normal file
View File

@@ -0,0 +1,75 @@
import { test, expect } from "@playwright/test";
test.describe("Dashboard", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/dashboard");
// Wait for WASM hydration and auth check to complete
await page.waitForSelector(".dashboard-page", { timeout: 15_000 });
});
test("dashboard page loads with page header", async ({ page }) => {
await expect(page.locator(".page-header")).toContainText("Dashboard");
});
test("default topic chips are visible", async ({ page }) => {
const topics = ["AI", "Technology", "Science", "Finance", "Writing", "Research"];
for (const topic of topics) {
await expect(
page.locator(".filter-tab", { hasText: topic })
).toBeVisible();
}
});
test("clicking a topic chip triggers search", async ({ page }) => {
const chip = page.locator(".filter-tab", { hasText: "AI" });
await chip.click();
// Either a loading state or results should appear
const searchingOrResults = page
.locator(".dashboard-loading, .news-grid, .dashboard-empty");
await expect(searchingOrResults.first()).toBeVisible({ timeout: 10_000 });
});
test("news cards render after search completes", async ({ page }) => {
// Click a topic to trigger search
await page.locator(".filter-tab", { hasText: "Technology" }).click();
// Wait for loading to finish
await page.waitForSelector(".dashboard-loading", {
state: "hidden",
timeout: 15_000,
}).catch(() => {
// Loading may already be done
});
// Either news cards or an empty state message should be visible
const content = page.locator(".news-grid .news-card, .dashboard-empty");
await expect(content.first()).toBeVisible({ timeout: 10_000 });
});
test("clicking a news card opens article detail panel", async ({ page }) => {
// Trigger a search and wait for results
await page.locator(".filter-tab", { hasText: "AI" }).click();
await page.waitForSelector(".dashboard-loading", {
state: "hidden",
timeout: 15_000,
}).catch(() => {});
const firstCard = page.locator(".news-card").first();
// Only test if cards are present (search results depend on live data)
if (await firstCard.isVisible().catch(() => false)) {
await firstCard.click();
await expect(page.locator(".dashboard-right, .dashboard-split")).toBeVisible();
}
});
test("settings toggle opens settings panel", async ({ page }) => {
const settingsBtn = page.locator(".settings-toggle");
await settingsBtn.click();
await expect(page.locator(".settings-panel")).toBeVisible();
await expect(page.locator(".settings-panel-title")).toBeVisible();
});
});

33
e2e/developer.spec.ts Normal file
View File

@@ -0,0 +1,33 @@
import { test, expect } from "@playwright/test";
test.describe("Developer section", () => {
test("agents page loads with sub-nav tabs", async ({ page }) => {
await page.goto("/developer/agents");
await page.waitForSelector(".developer-shell", { timeout: 15_000 });
const nav = page.locator(".sub-nav");
await expect(nav.locator("a", { hasText: "Agents" })).toBeVisible();
await expect(nav.locator("a", { hasText: "Flow" })).toBeVisible();
await expect(nav.locator("a", { hasText: "Analytics" })).toBeVisible();
});
test("agents page shows Coming Soon badge", async ({ page }) => {
await page.goto("/developer/agents");
await page.waitForSelector(".placeholder-page", { timeout: 15_000 });
await expect(page.locator(".placeholder-badge")).toContainText(
"Coming Soon"
);
await expect(page.locator("h2")).toContainText("Agent Builder");
});
test("analytics page loads via sub-nav", async ({ page }) => {
await page.goto("/developer/analytics");
await page.waitForSelector(".placeholder-page", { timeout: 15_000 });
await expect(page.locator("h2")).toContainText("Analytics");
await expect(page.locator(".placeholder-badge")).toContainText(
"Coming Soon"
);
});
});

52
e2e/navigation.spec.ts Normal file
View File

@@ -0,0 +1,52 @@
import { test, expect } from "@playwright/test";
test.describe("Sidebar navigation", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/dashboard");
await page.waitForSelector(".sidebar", { timeout: 15_000 });
});
test("sidebar links route to correct pages", async ({ page }) => {
const navTests = [
{ label: "Providers", url: /\/providers/ },
{ label: "Developer", url: /\/developer\/agents/ },
{ label: "Organization", url: /\/organization\/pricing/ },
{ label: "Dashboard", url: /\/dashboard/ },
];
for (const { label, url } of navTests) {
await page.locator(".sidebar-link", { hasText: label }).click();
await expect(page).toHaveURL(url, { timeout: 10_000 });
}
});
test("browser back/forward navigation works", async ({ page }) => {
// Navigate to Providers
await page.locator(".sidebar-link", { hasText: "Providers" }).click();
await expect(page).toHaveURL(/\/providers/);
// Navigate to Developer
await page.locator(".sidebar-link", { hasText: "Developer" }).click();
await expect(page).toHaveURL(/\/developer/);
// Go back
await page.goBack();
await expect(page).toHaveURL(/\/providers/);
// Go forward
await page.goForward();
await expect(page).toHaveURL(/\/developer/);
});
test("logo link navigates to dashboard", async ({ page }) => {
// Navigate away first
await page.locator(".sidebar-link", { hasText: "Providers" }).click();
await expect(page).toHaveURL(/\/providers/);
// Click the logo/brand in sidebar header
const logo = page.locator(".sidebar-brand, .sidebar-logo, .sidebar a").first();
await logo.click();
await expect(page).toHaveURL(/\/dashboard/);
});
});

41
e2e/organization.spec.ts Normal file
View File

@@ -0,0 +1,41 @@
import { test, expect } from "@playwright/test";
test.describe("Organization section", () => {
test("pricing page loads with three pricing cards", async ({ page }) => {
await page.goto("/organization/pricing");
await page.waitForSelector(".org-shell", { timeout: 15_000 });
const cards = page.locator(".pricing-card");
await expect(cards).toHaveCount(3);
});
test("pricing cards show Starter, Team, Enterprise tiers", async ({
page,
}) => {
await page.goto("/organization/pricing");
await page.waitForSelector(".org-shell", { timeout: 15_000 });
await expect(page.locator(".pricing-card", { hasText: "Starter" })).toBeVisible();
await expect(page.locator(".pricing-card", { hasText: "Team" })).toBeVisible();
await expect(page.locator(".pricing-card", { hasText: "Enterprise" })).toBeVisible();
});
test("organization dashboard loads with billing stats", async ({ page }) => {
await page.goto("/organization/dashboard");
await page.waitForSelector(".org-dashboard-page", { timeout: 15_000 });
await expect(page.locator(".page-header")).toContainText("Organization");
await expect(page.locator(".org-stats-bar")).toBeVisible();
await expect(page.locator(".org-stat").first()).toBeVisible();
});
test("member table is visible on org dashboard", async ({ page }) => {
await page.goto("/organization/dashboard");
await page.waitForSelector(".org-dashboard-page", { timeout: 15_000 });
await expect(page.locator(".org-table")).toBeVisible();
await expect(page.locator(".org-table thead")).toContainText("Name");
await expect(page.locator(".org-table thead")).toContainText("Email");
await expect(page.locator(".org-table thead")).toContainText("Role");
});
});

55
e2e/providers.spec.ts Normal file
View File

@@ -0,0 +1,55 @@
import { test, expect } from "@playwright/test";
test.describe("Providers page", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/providers");
await page.waitForSelector(".providers-page", { timeout: 15_000 });
});
test("providers page loads with header", async ({ page }) => {
await expect(page.locator(".page-header")).toContainText("Providers");
});
test("provider dropdown has Ollama selected by default", async ({
page,
}) => {
const providerSelect = page
.locator(".form-group")
.filter({ hasText: "Provider" })
.locator("select");
await expect(providerSelect).toHaveValue(/ollama/i);
});
test("changing provider updates the model dropdown", async ({ page }) => {
const providerSelect = page
.locator(".form-group")
.filter({ hasText: "Provider" })
.locator("select");
// Get current model options
const modelSelect = page
.locator(".form-group")
.filter({ hasText: /^Model/ })
.locator("select");
const initialOptions = await modelSelect.locator("option").allTextContents();
// Change to a different provider
await providerSelect.selectOption({ label: "OpenAI" });
// Wait for model list to update
await page.waitForTimeout(500);
const updatedOptions = await modelSelect.locator("option").allTextContents();
// Model options should differ between providers
expect(updatedOptions).not.toEqual(initialOptions);
});
test("save button shows confirmation feedback", async ({ page }) => {
const saveBtn = page.locator("button", { hasText: "Save Configuration" });
await saveBtn.click();
await expect(page.locator(".form-success")).toBeVisible({ timeout: 5_000 });
await expect(page.locator(".form-success")).toContainText("saved");
});
});

60
e2e/public.spec.ts Normal file
View File

@@ -0,0 +1,60 @@
import { test, expect } from "@playwright/test";
test.describe("Public pages", () => {
test("landing page loads with heading and nav links", async ({ page }) => {
await page.goto("/");
await expect(page.locator(".landing-logo").first()).toHaveText("CERTifAI");
await expect(page.locator(".landing-nav-links")).toBeVisible();
await expect(page.locator('a[href="#features"]')).toBeVisible();
await expect(page.locator('a[href="#how-it-works"]')).toBeVisible();
await expect(page.locator('a[href="#pricing"]')).toBeVisible();
});
test("landing page Log In link navigates to login route", async ({
page,
}) => {
await page.goto("/");
const loginLink = page
.locator(".landing-nav-actions a, .landing-nav-actions Link")
.filter({ hasText: "Log In" });
await loginLink.click();
await expect(page).toHaveURL(/\/login/);
});
test("impressum page loads with legal content", async ({ page }) => {
await page.goto("/impressum");
await expect(page.locator("h1")).toHaveText("Impressum");
await expect(
page.locator("h2", { hasText: "Information according to" })
).toBeVisible();
await expect(page.locator(".legal-content")).toContainText(
"CERTifAI GmbH"
);
});
test("privacy page loads with privacy content", async ({ page }) => {
await page.goto("/privacy");
await expect(page.locator("h1")).toHaveText("Privacy Policy");
await expect(
page.locator("h2", { hasText: "Introduction" })
).toBeVisible();
await expect(
page.locator("h2", { hasText: "Your Rights" })
).toBeVisible();
});
test("footer links are present on landing page", async ({ page }) => {
await page.goto("/");
const footer = page.locator(".landing-footer");
await expect(footer.locator('a:has-text("Impressum")')).toBeVisible();
await expect(
footer.locator('a:has-text("Privacy Policy")')
).toBeVisible();
});
});

View File

@@ -4,6 +4,7 @@
"type": "module",
"private": true,
"devDependencies": {
"@playwright/test": "^1.52.0",
"@types/bun": "latest"
},
"peerDependencies": {

40
playwright.config.ts Normal file
View File

@@ -0,0 +1,40 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [["html"], ["list"]],
timeout: 30_000,
use: {
baseURL: process.env.BASE_URL ?? "http://localhost:8000",
actionTimeout: 10_000,
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{
name: "setup",
testMatch: /auth\.setup\.ts/,
},
{
name: "public",
testMatch: /public\.spec\.ts/,
use: { ...devices["Desktop Chrome"] },
},
{
name: "authenticated",
testMatch: /\.spec\.ts$/,
testIgnore: /public\.spec\.ts$/,
dependencies: ["setup"],
use: {
...devices["Desktop Chrome"],
storageState: "e2e/.auth/user.json",
},
},
],
});

View File

@@ -440,7 +440,12 @@ pub async fn chat_complete(
let session = doc_to_chat_session(&session_doc);
// Resolve provider URL and model
let (base_url, model) = resolve_provider_url(&state, &session.provider, &session.model);
let (base_url, model) = resolve_provider_url(
&state.services.ollama_url,
&state.services.ollama_model,
&session.provider,
&session.model,
);
// Parse messages from JSON
let chat_msgs: Vec<serde_json::Value> = serde_json::from_str(&messages_json)
@@ -480,10 +485,22 @@ pub async fn chat_complete(
.ok_or_else(|| ServerFnError::new("empty LLM response"))
}
/// Resolve the base URL for a provider, falling back to server defaults.
/// Resolve the base URL for a provider, falling back to Ollama defaults.
///
/// # Arguments
///
/// * `ollama_url` - Default Ollama base URL from config
/// * `ollama_model` - Default Ollama model from config
/// * `provider` - Provider name (e.g. "openai", "anthropic", "huggingface")
/// * `model` - Model ID (may be empty for Ollama default)
///
/// # Returns
///
/// A `(base_url, model)` tuple resolved for the given provider.
#[cfg(feature = "server")]
fn resolve_provider_url(
state: &crate::infrastructure::ServerState,
pub(crate) fn resolve_provider_url(
ollama_url: &str,
ollama_model: &str,
provider: &str,
model: &str,
) -> (String, String) {
@@ -496,12 +513,229 @@ fn resolve_provider_url(
),
// Default to Ollama
_ => (
state.services.ollama_url.clone(),
ollama_url.to_string(),
if model.is_empty() {
state.services.ollama_model.clone()
ollama_model.to_string()
} else {
model.to_string()
},
),
}
}
#[cfg(test)]
mod tests {
// -----------------------------------------------------------------------
// BSON document conversion tests (server feature required)
// -----------------------------------------------------------------------
#[cfg(feature = "server")]
mod server_tests {
use super::super::{doc_to_chat_message, doc_to_chat_session, resolve_provider_url};
use crate::models::{ChatNamespace, ChatRole};
use mongodb::bson::{doc, oid::ObjectId, Document};
use pretty_assertions::assert_eq;
// -- doc_to_chat_session --
fn sample_session_doc() -> (ObjectId, Document) {
let oid = ObjectId::new();
let doc = doc! {
"_id": oid,
"user_sub": "user-42",
"title": "Test Session",
"namespace": "News",
"provider": "openai",
"model": "gpt-4",
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-02T00:00:00Z",
"article_url": "https://example.com/article",
};
(oid, doc)
}
#[test]
fn doc_to_chat_session_extracts_id_as_hex() {
let (oid, doc) = sample_session_doc();
let session = doc_to_chat_session(&doc);
assert_eq!(session.id, oid.to_hex());
}
#[test]
fn doc_to_chat_session_maps_news_namespace() {
let (_, doc) = sample_session_doc();
let session = doc_to_chat_session(&doc);
assert_eq!(session.namespace, ChatNamespace::News);
}
#[test]
fn doc_to_chat_session_defaults_to_general_for_unknown() {
let mut doc = sample_session_doc().1;
doc.insert("namespace", "SomethingElse");
let session = doc_to_chat_session(&doc);
assert_eq!(session.namespace, ChatNamespace::General);
}
#[test]
fn doc_to_chat_session_extracts_all_string_fields() {
let (_, doc) = sample_session_doc();
let session = doc_to_chat_session(&doc);
assert_eq!(session.user_sub, "user-42");
assert_eq!(session.title, "Test Session");
assert_eq!(session.provider, "openai");
assert_eq!(session.model, "gpt-4");
assert_eq!(session.created_at, "2025-01-01T00:00:00Z");
assert_eq!(session.updated_at, "2025-01-02T00:00:00Z");
}
#[test]
fn doc_to_chat_session_handles_missing_article_url() {
let oid = ObjectId::new();
let doc = doc! {
"_id": oid,
"user_sub": "u",
"title": "t",
"provider": "ollama",
"model": "m",
"created_at": "c",
"updated_at": "u",
};
let session = doc_to_chat_session(&doc);
assert_eq!(session.article_url, None);
}
#[test]
fn doc_to_chat_session_filters_empty_article_url() {
let oid = ObjectId::new();
let doc = doc! {
"_id": oid,
"user_sub": "u",
"title": "t",
"namespace": "News",
"provider": "ollama",
"model": "m",
"created_at": "c",
"updated_at": "u",
"article_url": "",
};
let session = doc_to_chat_session(&doc);
assert_eq!(session.article_url, None);
}
// -- doc_to_chat_message --
fn sample_message_doc() -> (ObjectId, Document) {
let oid = ObjectId::new();
let doc = doc! {
"_id": oid,
"session_id": "sess-1",
"role": "Assistant",
"content": "Hello there!",
"timestamp": "2025-01-01T12:00:00Z",
};
(oid, doc)
}
#[test]
fn doc_to_chat_message_extracts_id_as_hex() {
let (oid, doc) = sample_message_doc();
let msg = doc_to_chat_message(&doc);
assert_eq!(msg.id, oid.to_hex());
}
#[test]
fn doc_to_chat_message_maps_assistant_role() {
let (_, doc) = sample_message_doc();
let msg = doc_to_chat_message(&doc);
assert_eq!(msg.role, ChatRole::Assistant);
}
#[test]
fn doc_to_chat_message_maps_system_role() {
let mut doc = sample_message_doc().1;
doc.insert("role", "System");
let msg = doc_to_chat_message(&doc);
assert_eq!(msg.role, ChatRole::System);
}
#[test]
fn doc_to_chat_message_defaults_to_user_for_unknown() {
let mut doc = sample_message_doc().1;
doc.insert("role", "SomethingElse");
let msg = doc_to_chat_message(&doc);
assert_eq!(msg.role, ChatRole::User);
}
#[test]
fn doc_to_chat_message_extracts_content_and_timestamp() {
let (_, doc) = sample_message_doc();
let msg = doc_to_chat_message(&doc);
assert_eq!(msg.content, "Hello there!");
assert_eq!(msg.timestamp, "2025-01-01T12:00:00Z");
assert_eq!(msg.session_id, "sess-1");
}
#[test]
fn doc_to_chat_message_attachments_always_empty() {
let (_, doc) = sample_message_doc();
let msg = doc_to_chat_message(&doc);
assert!(msg.attachments.is_empty());
}
// -- resolve_provider_url --
const TEST_OLLAMA_URL: &str = "http://localhost:11434";
const TEST_OLLAMA_MODEL: &str = "llama3.1:8b";
#[test]
fn resolve_openai_returns_api_openai() {
let (url, model) =
resolve_provider_url(TEST_OLLAMA_URL, TEST_OLLAMA_MODEL, "openai", "gpt-4o");
assert_eq!(url, "https://api.openai.com");
assert_eq!(model, "gpt-4o");
}
#[test]
fn resolve_anthropic_returns_api_anthropic() {
let (url, model) = resolve_provider_url(
TEST_OLLAMA_URL,
TEST_OLLAMA_MODEL,
"anthropic",
"claude-3-opus",
);
assert_eq!(url, "https://api.anthropic.com");
assert_eq!(model, "claude-3-opus");
}
#[test]
fn resolve_huggingface_returns_model_url() {
let (url, model) = resolve_provider_url(
TEST_OLLAMA_URL,
TEST_OLLAMA_MODEL,
"huggingface",
"meta-llama/Llama-2-7b",
);
assert_eq!(
url,
"https://api-inference.huggingface.co/models/meta-llama/Llama-2-7b"
);
assert_eq!(model, "meta-llama/Llama-2-7b");
}
#[test]
fn resolve_unknown_defaults_to_ollama() {
let (url, model) =
resolve_provider_url(TEST_OLLAMA_URL, TEST_OLLAMA_MODEL, "ollama", "mistral:7b");
assert_eq!(url, TEST_OLLAMA_URL);
assert_eq!(model, "mistral:7b");
}
#[test]
fn resolve_empty_model_falls_back_to_server_default() {
let (url, model) =
resolve_provider_url(TEST_OLLAMA_URL, TEST_OLLAMA_MODEL, "ollama", "");
assert_eq!(url, TEST_OLLAMA_URL);
assert_eq!(model, TEST_OLLAMA_MODEL);
}
}
}

View File

@@ -72,7 +72,25 @@ mod inner {
}
let html = resp.text().await.ok()?;
let document = scraper::Html::parse_document(&html);
parse_article_html(&html)
}
/// Parse article text from raw HTML without any network I/O.
///
/// Uses a tiered extraction strategy:
/// 1. Try content within `<article>`, `<main>`, or `[role="main"]`
/// 2. Fall back to all `<p>` tags outside excluded containers
///
/// # Arguments
///
/// * `html` - Raw HTML string to parse
///
/// # Returns
///
/// The extracted text, or `None` if extraction yields < 100 chars.
/// Output is capped at 8000 characters.
pub(crate) fn parse_article_html(html: &str) -> Option<String> {
let document = scraper::Html::parse_document(html);
// Strategy 1: Extract from semantic article containers.
// Most news sites wrap the main content in <article>, <main>,
@@ -134,7 +152,7 @@ mod inner {
}
/// Sum the total character length of all collected text parts.
fn joined_len(parts: &[String]) -> usize {
pub(crate) fn joined_len(parts: &[String]) -> usize {
parts.iter().map(|s| s.len()).sum()
}
}
@@ -325,3 +343,150 @@ pub async fn chat_followup(
.map(|choice| choice.message.content.clone())
.ok_or_else(|| ServerFnError::new("Empty response from Ollama"))
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
// -----------------------------------------------------------------------
// FollowUpMessage serde tests
// -----------------------------------------------------------------------
#[test]
fn followup_message_serde_round_trip() {
let msg = FollowUpMessage {
role: "assistant".into(),
content: "Here is my answer.".into(),
};
let json = serde_json::to_string(&msg).expect("serialize FollowUpMessage");
let back: FollowUpMessage =
serde_json::from_str(&json).expect("deserialize FollowUpMessage");
assert_eq!(msg, back);
}
#[test]
fn followup_message_deserialize_from_json_literal() {
let json = r#"{"role":"system","content":"You are helpful."}"#;
let msg: FollowUpMessage = serde_json::from_str(json).expect("deserialize literal");
assert_eq!(msg.role, "system");
assert_eq!(msg.content, "You are helpful.");
}
// -----------------------------------------------------------------------
// joined_len and parse_article_html tests (server feature required)
// -----------------------------------------------------------------------
#[cfg(feature = "server")]
mod server_tests {
use super::super::inner::{joined_len, parse_article_html};
use pretty_assertions::assert_eq;
#[test]
fn joined_len_empty_input() {
assert_eq!(joined_len(&[]), 0);
}
#[test]
fn joined_len_sums_correctly() {
let parts = vec!["abc".into(), "de".into(), "fghij".into()];
assert_eq!(joined_len(&parts), 10);
}
// -------------------------------------------------------------------
// parse_article_html tests
// -------------------------------------------------------------------
// Helper: generate a string of given length from a repeated word.
fn lorem(len: usize) -> String {
"Lorem ipsum dolor sit amet consectetur adipiscing elit "
.repeat((len / 55) + 1)
.chars()
.take(len)
.collect()
}
#[test]
fn article_tag_extracts_text() {
let body = lorem(250);
let html = format!("<html><body><article><p>{body}</p></article></body></html>");
let result = parse_article_html(&html);
assert!(result.is_some(), "expected Some for article tag");
assert!(result.unwrap().contains("Lorem"));
}
#[test]
fn main_tag_extracts_text() {
let body = lorem(250);
let html = format!("<html><body><main><p>{body}</p></main></body></html>");
let result = parse_article_html(&html);
assert!(result.is_some(), "expected Some for main tag");
}
#[test]
fn fallback_to_p_tags_when_article_main_yield_little() {
// No <article>/<main>, so falls back to <p> tags
let body = lorem(250);
let html = format!("<html><body><div><p>{body}</p></div></body></html>");
let result = parse_article_html(&html);
assert!(result.is_some(), "expected fallback to <p> tags");
}
#[test]
fn excludes_nav_footer_aside_content() {
// Content only inside excluded containers -- should be excluded
let body = lorem(250);
let html = format!(
"<html><body>\
<nav><p>{body}</p></nav>\
<footer><p>{body}</p></footer>\
<aside><p>{body}</p></aside>\
</body></html>"
);
let result = parse_article_html(&html);
assert!(result.is_none(), "expected None for excluded-only content");
}
#[test]
fn returns_none_when_text_too_short() {
let html = "<html><body><p>Short.</p></body></html>";
let result = parse_article_html(html);
assert!(result.is_none(), "expected None for short text");
}
#[test]
fn truncates_at_8000_chars() {
let body = lorem(10000);
let html = format!("<html><body><article><p>{body}</p></article></body></html>");
let result = parse_article_html(&html).expect("expected Some");
assert!(
result.len() <= 8000,
"expected <= 8000 chars, got {}",
result.len()
);
}
#[test]
fn skips_fragments_under_30_chars() {
// Only fragments < 30 chars -- should yield None
let html = "<html><body><article>\
<p>Short frag one</p>\
<p>Another tiny bit</p>\
</article></body></html>";
let result = parse_article_html(html);
assert!(result.is_none(), "expected None for tiny fragments");
}
#[test]
fn extracts_from_role_main_attribute() {
let body = lorem(250);
let html = format!(
"<html><body>\
<div role=\"main\"><p>{body}</p></div>\
</body></html>"
);
let result = parse_article_html(&html);
assert!(result.is_some(), "expected Some for role=main");
}
}
}

View File

@@ -146,3 +146,30 @@ pub async fn send_chat_request(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn provider_message_serde_round_trip() {
let msg = ProviderMessage {
role: "assistant".into(),
content: "Hello, world!".into(),
};
let json = serde_json::to_string(&msg).expect("serialize ProviderMessage");
let back: ProviderMessage =
serde_json::from_str(&json).expect("deserialize ProviderMessage");
assert_eq!(msg.role, back.role);
assert_eq!(msg.content, back.content);
}
#[test]
fn provider_message_deserialize_from_json_literal() {
let json = r#"{"role":"user","content":"What is Rust?"}"#;
let msg: ProviderMessage = serde_json::from_str(json).expect("deserialize from literal");
assert_eq!(msg.role, "user");
assert_eq!(msg.content, "What is Rust?");
}
}

View File

@@ -44,3 +44,91 @@ pub struct User {
/// Avatar / profile picture URL.
pub avatar_url: String,
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn user_state_inner_default_has_empty_strings() {
let inner = UserStateInner::default();
assert_eq!(inner.sub, "");
assert_eq!(inner.access_token, "");
assert_eq!(inner.refresh_token, "");
assert_eq!(inner.user.email, "");
assert_eq!(inner.user.name, "");
assert_eq!(inner.user.avatar_url, "");
}
#[test]
fn user_default_has_empty_strings() {
let user = User::default();
assert_eq!(user.email, "");
assert_eq!(user.name, "");
assert_eq!(user.avatar_url, "");
}
#[test]
fn user_state_inner_serde_round_trip() {
let inner = UserStateInner {
sub: "user-123".into(),
access_token: "tok-abc".into(),
refresh_token: "ref-xyz".into(),
user: User {
email: "a@b.com".into(),
name: "Alice".into(),
avatar_url: "https://img.example.com/a.png".into(),
},
};
let json = serde_json::to_string(&inner).expect("serialize UserStateInner");
let back: UserStateInner = serde_json::from_str(&json).expect("deserialize UserStateInner");
assert_eq!(inner.sub, back.sub);
assert_eq!(inner.access_token, back.access_token);
assert_eq!(inner.refresh_token, back.refresh_token);
assert_eq!(inner.user.email, back.user.email);
assert_eq!(inner.user.name, back.user.name);
assert_eq!(inner.user.avatar_url, back.user.avatar_url);
}
#[test]
fn user_state_from_inner_and_deref() {
let inner = UserStateInner {
sub: "sub-1".into(),
access_token: "at".into(),
refresh_token: "rt".into(),
user: User {
email: "e@e.com".into(),
name: "Eve".into(),
avatar_url: "".into(),
},
};
let state = UserState::from(inner);
// Deref should give access to inner fields
assert_eq!(state.sub, "sub-1");
assert_eq!(state.user.name, "Eve");
}
#[test]
fn user_serde_round_trip() {
let user = User {
email: "bob@test.com".into(),
name: "Bob".into(),
avatar_url: "https://avatars.io/bob".into(),
};
let json = serde_json::to_string(&user).expect("serialize User");
let back: User = serde_json::from_str(&json).expect("deserialize User");
assert_eq!(user.email, back.email);
assert_eq!(user.name, back.name);
assert_eq!(user.avatar_url, back.avatar_url);
}
#[test]
fn user_state_clone_is_cheap() {
let inner = UserStateInner::default();
let state = UserState::from(inner);
let cloned = state.clone();
// Both point to the same Arc allocation
assert_eq!(state.sub, cloned.sub);
}
}