chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const a11yRoutes = [
|
||||
"/400",
|
||||
"/401",
|
||||
"/403",
|
||||
"/408",
|
||||
"/429",
|
||||
"/500",
|
||||
"/502",
|
||||
"/503",
|
||||
"/offline",
|
||||
"/maintenance",
|
||||
"/status",
|
||||
"/route-that-does-not-exist",
|
||||
];
|
||||
|
||||
test.describe("A11y — Resilience Routes", () => {
|
||||
for (const route of a11yRoutes) {
|
||||
test(`${route} exposes semantic main landmark and heading`, async ({ page }) => {
|
||||
await page.goto(route);
|
||||
|
||||
const mainLandmarks = page.locator("main, [role='main']");
|
||||
await expect(mainLandmarks).toHaveCount(1);
|
||||
|
||||
const h1s = page.locator("h1");
|
||||
await expect(h1s).toHaveCount(1);
|
||||
|
||||
const actionableControls = page.locator("a[href], button");
|
||||
await expect(actionableControls.first()).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test("keyboard navigation reaches first actionable element on error page", async ({ page }) => {
|
||||
await page.goto("/500");
|
||||
|
||||
await page.keyboard.press("Tab");
|
||||
const activeTag = await page.evaluate(
|
||||
() => document.activeElement?.tagName?.toLowerCase() || null
|
||||
);
|
||||
|
||||
expect(activeTag).not.toBeNull();
|
||||
expect(["a", "button"]).toContain(activeTag as string);
|
||||
});
|
||||
|
||||
test("status page exposes live region during loading or status section after load", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/status");
|
||||
|
||||
const liveRegion = page.locator("[role='status']");
|
||||
const statusSection = page.getByText("Provider Circuit Breaker State");
|
||||
|
||||
const hasLiveRegion = (await liveRegion.count()) > 0;
|
||||
const hasStatusSection = (await statusSection.count()) > 0;
|
||||
|
||||
expect(hasLiveRegion || hasStatusSection).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* tests/e2e/a11y.spec.ts
|
||||
*
|
||||
* Accessibility gate using @axe-core/playwright (Task 13 — Fase 7).
|
||||
*
|
||||
* NIGHTLY advisory: this suite is scheduled in the NIGHTLY CI job, not in the
|
||||
* per-PR job, because axe analysis adds ~10–20 s per page and the results are
|
||||
* frozen baselines (see approach below).
|
||||
*
|
||||
* Approach — freeze-and-alert (not fail-on-first-violation):
|
||||
* 1. Run axe on each key page.
|
||||
* 2. Count `violations.length` per page.
|
||||
* 3. Assert the count has NOT increased since the frozen baseline.
|
||||
* 4. Report violations in the test output so they are visible in CI logs.
|
||||
*
|
||||
* This means:
|
||||
* - Existing violations are GRANDFATHERED (baseline frozen).
|
||||
* - A new violation (count grows) FAILS the gate — catraca `down`.
|
||||
* - Fixing a violation (count drops) passes + you can lower the baseline.
|
||||
*
|
||||
* Graceful degradation:
|
||||
* - If @axe-core/playwright is not installed the entire suite is skipped
|
||||
* with a clear message instead of crashing the job.
|
||||
* - The frozen baselines below are ADVISORY defaults (0). On the first real
|
||||
* run update them to the actual counts (grep "axeViolationCount" in CI logs).
|
||||
*
|
||||
* Pages audited (key dashboard surfaces):
|
||||
* /dashboard — main overview
|
||||
* /dashboard/providers — provider management (most complex UI surface)
|
||||
* /login — public auth gate (a11y critical for users)
|
||||
* /dashboard/settings — settings (redirects to /dashboard/settings/general)
|
||||
*
|
||||
* Run locally (requires the app running on localhost:20128):
|
||||
* npx playwright test tests/e2e/a11y.spec.ts --headed
|
||||
*/
|
||||
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conditional import — skip entire suite if @axe-core/playwright is absent.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let AxeBuilder: (new (args: { page: Page }) => {
|
||||
analyze(): Promise<{ violations: Array<{ id: string; description: string; impact: string | null; nodes: unknown[] }> }>;
|
||||
withTags(tags: string[]): unknown;
|
||||
exclude(selector: string): unknown;
|
||||
disableRules(rules: string[]): unknown;
|
||||
}) | null = null;
|
||||
|
||||
try {
|
||||
// Dynamic import so the module parse does not fail when the package is absent.
|
||||
const mod = await import("@axe-core/playwright");
|
||||
AxeBuilder = mod.default ?? (mod as unknown as { AxeBuilder: typeof AxeBuilder }).AxeBuilder ?? null;
|
||||
} catch {
|
||||
// Package not installed — suite will skip gracefully below.
|
||||
AxeBuilder = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frozen violation baselines.
|
||||
//
|
||||
// Update these after the first real run by reading the "axeViolationCount"
|
||||
// lines from the CI log and setting each value to the actual count.
|
||||
// Format: { [pageLabel]: maxAllowedViolations }
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Frozen from the first real nightly measurement (run 27852779527, REQUIRE_AXE=1,
|
||||
// wcag2a/2aa/21a/21aa). Each value is the actual `axeViolationCount` for that page —
|
||||
// existing violations are grandfathered; a NEW violation (count grows) fails the gate.
|
||||
// Lower a value (and re-run) whenever a violation is fixed.
|
||||
const VIOLATION_BASELINES: Record<string, number> = {
|
||||
"/login": 1,
|
||||
"/dashboard": 4,
|
||||
"/dashboard/providers": 3,
|
||||
"/dashboard/settings": 5,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type AxeViolation = {
|
||||
id: string;
|
||||
description: string;
|
||||
impact: string | null;
|
||||
nodes: unknown[];
|
||||
};
|
||||
|
||||
async function runAxe(
|
||||
page: Page,
|
||||
label: string
|
||||
): Promise<AxeViolation[]> {
|
||||
if (!AxeBuilder) {
|
||||
throw new Error("@axe-core/playwright not available");
|
||||
}
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
// Exclude third-party iframes / injected widgets that we don't control.
|
||||
.exclude("[data-axe-exclude]")
|
||||
.analyze();
|
||||
|
||||
// Emit machine-parseable line for CI baseline tracking.
|
||||
console.log(`axeViolationCount page=${label} count=${results.violations.length}`);
|
||||
|
||||
if (results.violations.length > 0) {
|
||||
const summary = results.violations
|
||||
.map((v) => ` [${v.impact ?? "unknown"}] ${v.id}: ${v.description} (${(v.nodes as unknown[]).length} nodes)`)
|
||||
.join("\n");
|
||||
console.log(`axeViolations page=${label}:\n${summary}`);
|
||||
}
|
||||
|
||||
return results.violations;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test suite
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("A11y — Dashboard key surfaces (@axe-core, nightly advisory)", () => {
|
||||
test.beforeAll(() => {
|
||||
if (!AxeBuilder) {
|
||||
// Log once; individual tests will call test.skip().
|
||||
console.log(
|
||||
"[a11y.spec.ts] SKIP: @axe-core/playwright is not installed.\n" +
|
||||
"Install with: npm install --save-dev @axe-core/playwright"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// /login — public auth gate
|
||||
// -------------------------------------------------------------------------
|
||||
test("/login — axe wcag2a/wcag2aa violations must not exceed baseline", async ({ page }) => {
|
||||
if (!AxeBuilder || process.env.REQUIRE_AXE !== "1") {
|
||||
// Nightly-only: the real axe analysis (~10–20 s/page) runs in the nightly job
|
||||
// (REQUIRE_AXE=1), NOT in the per-PR e2e shards — a11y.spec.ts is matched by the
|
||||
// per-PR `tests/e2e/*.spec.ts` glob, so without this gate installing the package
|
||||
// would silently flip axe on for every PR (and fail at baseline 0).
|
||||
test.skip(
|
||||
true,
|
||||
AxeBuilder
|
||||
? "axe analysis runs in the nightly job only (set REQUIRE_AXE=1)"
|
||||
: "@axe-core/playwright not installed"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await page.goto("/login");
|
||||
await page.locator("body").waitFor({ state: "visible" });
|
||||
|
||||
const violations = await runAxe(page, "/login");
|
||||
const baseline = VIOLATION_BASELINES["/login"] ?? 0;
|
||||
|
||||
expect(violations.length).toBeLessThanOrEqual(
|
||||
baseline,
|
||||
`New a11y violations introduced on /login. ` +
|
||||
`Expected ≤${baseline}, got ${violations.length}. ` +
|
||||
`Run axe locally and update VIOLATION_BASELINES["/login"] if the new count is intentional.`
|
||||
);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// /dashboard — main overview
|
||||
// -------------------------------------------------------------------------
|
||||
test("/dashboard — axe wcag2a/wcag2aa violations must not exceed baseline", async ({ page }) => {
|
||||
if (!AxeBuilder || process.env.REQUIRE_AXE !== "1") {
|
||||
// Nightly-only: the real axe analysis (~10–20 s/page) runs in the nightly job
|
||||
// (REQUIRE_AXE=1), NOT in the per-PR e2e shards — a11y.spec.ts is matched by the
|
||||
// per-PR `tests/e2e/*.spec.ts` glob, so without this gate installing the package
|
||||
// would silently flip axe on for every PR (and fail at baseline 0).
|
||||
test.skip(
|
||||
true,
|
||||
AxeBuilder
|
||||
? "axe analysis runs in the nightly job only (set REQUIRE_AXE=1)"
|
||||
: "@axe-core/playwright not installed"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard");
|
||||
|
||||
const violations = await runAxe(page, "/dashboard");
|
||||
const baseline = VIOLATION_BASELINES["/dashboard"] ?? 0;
|
||||
|
||||
expect(violations.length).toBeLessThanOrEqual(
|
||||
baseline,
|
||||
`New a11y violations introduced on /dashboard. ` +
|
||||
`Expected ≤${baseline}, got ${violations.length}.`
|
||||
);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// /dashboard/providers — provider management (most complex UI surface)
|
||||
// -------------------------------------------------------------------------
|
||||
test("/dashboard/providers — axe wcag2a/wcag2aa violations must not exceed baseline", async ({
|
||||
page,
|
||||
}) => {
|
||||
if (!AxeBuilder || process.env.REQUIRE_AXE !== "1") {
|
||||
// Nightly-only: the real axe analysis (~10–20 s/page) runs in the nightly job
|
||||
// (REQUIRE_AXE=1), NOT in the per-PR e2e shards — a11y.spec.ts is matched by the
|
||||
// per-PR `tests/e2e/*.spec.ts` glob, so without this gate installing the package
|
||||
// would silently flip axe on for every PR (and fail at baseline 0).
|
||||
test.skip(
|
||||
true,
|
||||
AxeBuilder
|
||||
? "axe analysis runs in the nightly job only (set REQUIRE_AXE=1)"
|
||||
: "@axe-core/playwright not installed"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/providers");
|
||||
|
||||
const violations = await runAxe(page, "/dashboard/providers");
|
||||
const baseline = VIOLATION_BASELINES["/dashboard/providers"] ?? 0;
|
||||
|
||||
expect(violations.length).toBeLessThanOrEqual(
|
||||
baseline,
|
||||
`New a11y violations introduced on /dashboard/providers. ` +
|
||||
`Expected ≤${baseline}, got ${violations.length}.`
|
||||
);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// /dashboard/settings — settings area
|
||||
// -------------------------------------------------------------------------
|
||||
test("/dashboard/settings — axe wcag2a/wcag2aa violations must not exceed baseline", async ({
|
||||
page,
|
||||
}) => {
|
||||
if (!AxeBuilder || process.env.REQUIRE_AXE !== "1") {
|
||||
// Nightly-only: the real axe analysis (~10–20 s/page) runs in the nightly job
|
||||
// (REQUIRE_AXE=1), NOT in the per-PR e2e shards — a11y.spec.ts is matched by the
|
||||
// per-PR `tests/e2e/*.spec.ts` glob, so without this gate installing the package
|
||||
// would silently flip axe on for every PR (and fail at baseline 0).
|
||||
test.skip(
|
||||
true,
|
||||
AxeBuilder
|
||||
? "axe analysis runs in the nightly job only (set REQUIRE_AXE=1)"
|
||||
: "@axe-core/playwright not installed"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// The settings route redirects to /dashboard/settings/general; follow it.
|
||||
await gotoDashboardRoute(page, "/dashboard/settings");
|
||||
|
||||
const violations = await runAxe(page, "/dashboard/settings");
|
||||
const baseline = VIOLATION_BASELINES["/dashboard/settings"] ?? 0;
|
||||
|
||||
expect(violations.length).toBeLessThanOrEqual(
|
||||
baseline,
|
||||
`New a11y violations introduced on /dashboard/settings. ` +
|
||||
`Expected ≤${baseline}, got ${violations.length}.`
|
||||
);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Regression guard: suite is skippable but the skip reason must be explicit.
|
||||
// This test always runs (no AxeBuilder check) and verifies the skip is
|
||||
// legitimate (package absent) and not an infrastructure failure.
|
||||
// -------------------------------------------------------------------------
|
||||
test("axe package availability is declared (meta-test)", async () => {
|
||||
if (AxeBuilder !== null) {
|
||||
// Package is present — nothing to check.
|
||||
expect(AxeBuilder).toBeTruthy();
|
||||
} else {
|
||||
// Package absent — this is acceptable in PR CI; fatal in the NIGHTLY job.
|
||||
// In the nightly job, set REQUIRE_AXE=1 and the check below will fail.
|
||||
const requireAxe = process.env.REQUIRE_AXE === "1";
|
||||
if (requireAxe) {
|
||||
throw new Error(
|
||||
"REQUIRE_AXE=1 but @axe-core/playwright is not installed. " +
|
||||
"Add it as a devDependency and run npm install."
|
||||
);
|
||||
}
|
||||
// Advisory skip in PR context.
|
||||
test.skip(true, "@axe-core/playwright not installed — advisory skip in PR context");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* E2E — Cross-page smoke: AgentBridge → Traffic Inspector
|
||||
*
|
||||
* Verifies that the integration between AgentBridge and Traffic Inspector
|
||||
* works end-to-end:
|
||||
* 1. AgentBridge page is accessible
|
||||
* 2. "View in Traffic Inspector" link/button navigates to the Inspector
|
||||
* 3. Traffic Inspector receives/shows source=agent-bridge entries when
|
||||
* an AgentBridge capture is active
|
||||
*
|
||||
* CI behaviour: marked `.skip` unless `RUN_CROSS_E2E=1` is set.
|
||||
* These tests are best-effort: they verify page-level integration, not
|
||||
* live MITM traffic (which requires real IDE agent activity).
|
||||
*
|
||||
* To run locally:
|
||||
* RUN_CROSS_E2E=1 npx playwright test tests/e2e/agent-bridge-traffic-cross.spec.ts
|
||||
*/
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
|
||||
const SKIP = !process.env["RUN_CROSS_E2E"];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function isAuthenticated(page: Page): Promise<boolean> {
|
||||
return !page.url().includes("/login");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("AgentBridge ↔ Traffic Inspector cross-page integration", () => {
|
||||
test.skip(SKIP, "Set RUN_CROSS_E2E=1 to run cross-page E2E tests");
|
||||
|
||||
test("sidebar shows both agent-bridge and traffic-inspector entries", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
await page.waitForLoadState("networkidle");
|
||||
if (!(await isAuthenticated(page))) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
// Both Tools items should be in the sidebar
|
||||
const agentBridgeLink = page.locator(
|
||||
"a[href*='agent-bridge'], [data-testid='sidebar-agent-bridge']"
|
||||
).first();
|
||||
const inspectorLink = page.locator(
|
||||
"a[href*='traffic-inspector'], [data-testid='sidebar-traffic-inspector']"
|
||||
).first();
|
||||
|
||||
await expect(agentBridgeLink).toBeVisible({ timeout: 5000 });
|
||||
await expect(inspectorLink).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("View in Traffic Inspector link from AgentBridge navigates correctly", async ({ page }) => {
|
||||
await page.goto("/dashboard/tools/agent-bridge");
|
||||
await page.waitForLoadState("networkidle");
|
||||
if (!(await isAuthenticated(page))) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
// Look for a "View traffic" / "Traffic Inspector" link on the AgentBridge page
|
||||
const viewTrafficLink = page.locator(
|
||||
"a[href*='traffic-inspector'], a:has-text('Traffic Inspector'), [data-testid='view-traffic-link']"
|
||||
).first();
|
||||
const isVisible = await viewTrafficLink.isVisible({ timeout: 5000 }).catch(() => false);
|
||||
if (!isVisible) {
|
||||
// Quick links section may not render without providers configured
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
await viewTrafficLink.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
expect(page.url()).toContain("traffic-inspector");
|
||||
});
|
||||
|
||||
test("Traffic Inspector source filter includes agent-bridge option", async ({ page }) => {
|
||||
await page.goto("/dashboard/tools/traffic-inspector");
|
||||
await page.waitForLoadState("networkidle");
|
||||
if (!(await isAuthenticated(page))) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
// Source filter dropdown should list agent-bridge as an option
|
||||
const sourceFilter = page.locator(
|
||||
"[data-testid='source-filter'], select[name='source'], [aria-label*='source']"
|
||||
).first();
|
||||
const isVisible = await sourceFilter.isVisible({ timeout: 5000 }).catch(() => false);
|
||||
if (!isVisible) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
// Open the dropdown
|
||||
await sourceFilter.click();
|
||||
const agentBridgeOption = page.locator(
|
||||
"option[value='agent-bridge'], [data-value='agent-bridge'], li:has-text('Agent Bridge'), li:has-text('agent-bridge')"
|
||||
).first();
|
||||
await expect(agentBridgeOption).toBeVisible({ timeout: 3000 });
|
||||
});
|
||||
|
||||
test("AgentBridge server control buttons exist and are interactable", async ({ page }) => {
|
||||
await page.goto("/dashboard/tools/agent-bridge");
|
||||
await page.waitForLoadState("networkidle");
|
||||
if (!(await isAuthenticated(page))) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
// Start / Stop / Restart buttons should be present in server card
|
||||
const startBtn = page.locator(
|
||||
"button:has-text('Start'), button:has-text('Start Server'), [data-testid='start-server-btn']"
|
||||
).first();
|
||||
const isVisible = await startBtn.isVisible({ timeout: 5000 }).catch(() => false);
|
||||
if (!isVisible) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
// Verify button is not disabled in an error state
|
||||
const disabled = await startBtn.getAttribute("disabled");
|
||||
// We just check it exists and is rendered — not clicking (would spawn the MITM server)
|
||||
await expect(startBtn).toBeVisible();
|
||||
expect(disabled === null || disabled === "false").toBe(true);
|
||||
});
|
||||
|
||||
test("navigating from Inspector back to AgentBridge preserves state", async ({ page }) => {
|
||||
// Navigate to Inspector first
|
||||
await page.goto("/dashboard/tools/traffic-inspector");
|
||||
await page.waitForLoadState("networkidle");
|
||||
if (!(await isAuthenticated(page))) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
// Then navigate to AgentBridge
|
||||
await page.goto("/dashboard/tools/agent-bridge");
|
||||
await page.waitForLoadState("networkidle");
|
||||
// Page should render without errors
|
||||
const errorBoundary = page.locator("[data-testid='error-boundary'], text=Something went wrong");
|
||||
await expect(errorBoundary).not.toBeVisible();
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Traffic Inspector shows AgentBridge mode as always-on in capture sources", async ({ page }) => {
|
||||
await page.goto("/dashboard/tools/traffic-inspector");
|
||||
await page.waitForLoadState("networkidle");
|
||||
if (!(await isAuthenticated(page))) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
// AgentBridge capture mode should be shown as active/always-on
|
||||
const agentBridgeToggle = page.locator(
|
||||
"[data-testid='capture-agent-bridge'], [aria-label*='AgentBridge'], text=AgentBridge"
|
||||
).first();
|
||||
await expect(agentBridgeToggle).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* E2E — AgentBridge page smoke tests
|
||||
*
|
||||
* These tests require the OmniRoute server to be running at http://localhost:20128
|
||||
* (or the URL in PLAYWRIGHT_BASE_URL / baseURL in playwright.config.ts).
|
||||
*
|
||||
* CI behaviour: tests are marked `.skip` unless the env var
|
||||
* `RUN_AGENT_BRIDGE_E2E=1` is set, since they require a full server process
|
||||
* AND port 443 privileges (or mock). In CI the unit/integration suites provide
|
||||
* functional coverage; the E2E layer verifies UI navigation and wiring.
|
||||
*
|
||||
* To run locally:
|
||||
* RUN_AGENT_BRIDGE_E2E=1 npx playwright test tests/e2e/agent-bridge.spec.ts
|
||||
*/
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
|
||||
const SKIP = !process.env["RUN_AGENT_BRIDGE_E2E"];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function navigateToAgentBridge(page: Page): Promise<void> {
|
||||
await page.goto("/dashboard/tools/agent-bridge");
|
||||
// Wait for the page to settle (auth redirect or dashboard render)
|
||||
await page.waitForLoadState("networkidle");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("AgentBridge page", () => {
|
||||
test.skip(SKIP, "Set RUN_AGENT_BRIDGE_E2E=1 to run AgentBridge E2E tests");
|
||||
|
||||
test("page renders and shows heading", async ({ page }) => {
|
||||
await navigateToAgentBridge(page);
|
||||
// Should render AgentBridge heading (login may redirect first — tolerate both)
|
||||
const url = page.url();
|
||||
if (url.includes("/login")) {
|
||||
// Server is auth-protected; the page route itself exists
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
return;
|
||||
}
|
||||
await expect(page.locator("h1, [data-testid='agent-bridge-heading']").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("page renders 9 agent cards (or empty-providers state)", async ({ page }) => {
|
||||
await navigateToAgentBridge(page);
|
||||
const url = page.url();
|
||||
if (url.includes("/login")) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
// Either: 9 agent cards are visible
|
||||
// OR: empty-providers state is shown (no providers configured yet)
|
||||
const agentCards = page.locator("[data-testid='agent-card']");
|
||||
const emptyState = page.locator("[data-testid='empty-providers-state'], [data-testid='agent-bridge-empty']");
|
||||
|
||||
const cardCount = await agentCards.count();
|
||||
const emptyVisible = await emptyState.isVisible().catch(() => false);
|
||||
|
||||
expect(cardCount === 9 || emptyVisible).toBe(true);
|
||||
});
|
||||
|
||||
test("each agent card shows agent name", async ({ page }) => {
|
||||
await navigateToAgentBridge(page);
|
||||
const url = page.url();
|
||||
if (url.includes("/login")) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
const agentCards = page.locator("[data-testid='agent-card']");
|
||||
const count = await agentCards.count();
|
||||
if (count === 0) {
|
||||
// Empty providers state — skip the rest
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
expect(count).toBe(9);
|
||||
// Spot-check: Antigravity and GitHub Copilot cards exist
|
||||
await expect(page.locator("text=Antigravity").first()).toBeVisible();
|
||||
await expect(page.locator("text=Copilot, text=GitHub Copilot").first()).toBeVisible().catch(async () => {
|
||||
// Accept either name variant
|
||||
await expect(page.locator("text=Copilot").first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test("AgentBridge Server Card is visible", async ({ page }) => {
|
||||
await navigateToAgentBridge(page);
|
||||
const url = page.url();
|
||||
if (url.includes("/login")) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
const serverCard = page.locator(
|
||||
"[data-testid='agent-bridge-server-card'], [data-testid='server-card']"
|
||||
);
|
||||
await expect(serverCard.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("Setup wizard opens when Setup button is clicked", async ({ page }) => {
|
||||
await navigateToAgentBridge(page);
|
||||
const url = page.url();
|
||||
if (url.includes("/login")) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
const agentCards = page.locator("[data-testid='agent-card']");
|
||||
const count = await agentCards.count();
|
||||
if (count === 0) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
// Click "Setup wizard" on the first card that has one visible
|
||||
const setupButton = page.locator("[data-testid='setup-wizard-btn'], button:has-text('Setup wizard')").first();
|
||||
const isVisible = await setupButton.isVisible().catch(() => false);
|
||||
if (!isVisible) {
|
||||
// All agents already set up — skip wizard open test
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
await setupButton.click();
|
||||
// Wizard modal should appear
|
||||
const wizard = page.locator("[data-testid='setup-wizard'], [role='dialog']").first();
|
||||
await expect(wizard).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("DNS toggle interaction does not crash the page", async ({ page }) => {
|
||||
await navigateToAgentBridge(page);
|
||||
const url = page.url();
|
||||
if (url.includes("/login")) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
const dnsToggle = page.locator("[data-testid='dns-toggle']").first();
|
||||
const isVisible = await dnsToggle.isVisible().catch(() => false);
|
||||
if (!isVisible) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
// Click the toggle — may show a sudo prompt modal or update state
|
||||
await dnsToggle.click();
|
||||
// Page should not crash (no error boundary)
|
||||
await expect(page.locator("[data-testid='error-boundary']")).not.toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
});
|
||||
|
||||
test("redirect from /dashboard/system/mitm-proxy to agent-bridge", async ({ page }) => {
|
||||
// Old mitm-proxy URL should redirect or show moved notice
|
||||
await page.goto("/dashboard/system/mitm-proxy");
|
||||
await page.waitForLoadState("networkidle");
|
||||
const url = page.url();
|
||||
// Should redirect to agent-bridge or show a moved banner
|
||||
const isRedirected = url.includes("agent-bridge");
|
||||
const hasBanner = await page.locator("text=moved, text=AgentBridge").first().isVisible().catch(() => false);
|
||||
expect(isRedirected || hasBanner).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { expect, test, type Route } from "@playwright/test";
|
||||
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
|
||||
|
||||
const NAVIGATION_TIMEOUT_MS = 300_000;
|
||||
|
||||
type AgentSkill = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: "api" | "cli";
|
||||
area: string;
|
||||
icon: string;
|
||||
endpoints?: string[];
|
||||
cliCommands?: string[];
|
||||
rawUrl: string;
|
||||
githubUrl: string;
|
||||
};
|
||||
|
||||
type SkillCoverage = {
|
||||
api: { have: number; total: number };
|
||||
cli: { have: number; total: number };
|
||||
totalSkills: number;
|
||||
generatedAt: string;
|
||||
};
|
||||
|
||||
function makeAgentSkills(): AgentSkill[] {
|
||||
const skills: AgentSkill[] = [];
|
||||
for (let i = 0; i < 22; i++) {
|
||||
skills.push({
|
||||
id: `omni-skill-${i}`,
|
||||
name: `API Skill ${i}`,
|
||||
description: `API skill description ${i}`,
|
||||
category: "api",
|
||||
area: `area-${i}`,
|
||||
icon: "api",
|
||||
endpoints: [`GET /api/skill-${i}`],
|
||||
rawUrl: `https://raw.githubusercontent.com/example/OmniRoute/main/skills/omni-skill-${i}/SKILL.md`,
|
||||
githubUrl: `https://github.com/example/OmniRoute/blob/main/skills/omni-skill-${i}/SKILL.md`,
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < 20; i++) {
|
||||
skills.push({
|
||||
id: `cli-skill-${i}`,
|
||||
name: `CLI Skill ${i}`,
|
||||
description: `CLI skill description ${i}`,
|
||||
category: "cli",
|
||||
area: `cli-area-${i}`,
|
||||
icon: "terminal",
|
||||
cliCommands: [`skill${i} run`],
|
||||
rawUrl: `https://raw.githubusercontent.com/example/OmniRoute/main/skills/cli-skill-${i}/SKILL.md`,
|
||||
githubUrl: `https://github.com/example/OmniRoute/blob/main/skills/cli-skill-${i}/SKILL.md`,
|
||||
});
|
||||
}
|
||||
return skills;
|
||||
}
|
||||
|
||||
const FULL_COVERAGE: SkillCoverage = {
|
||||
api: { have: 22, total: 22 },
|
||||
cli: { have: 20, total: 20 },
|
||||
totalSkills: 42,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function fulfillText(route: Route, body: string, status = 200) {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: "text/markdown; charset=utf-8",
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Agent Skills page", () => {
|
||||
test.setTimeout(600_000);
|
||||
|
||||
test("renders SkillsConceptCard with data-testid skills-concept-card-agent", async ({
|
||||
page,
|
||||
}) => {
|
||||
const skills = makeAgentSkills();
|
||||
|
||||
await page.route(/\/api\/agent-skills(?:\?.*)?$/, async (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
await fulfillJson(route, { skills, coverage: FULL_COVERAGE });
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/agent-skills", {
|
||||
timeoutMs: NAVIGATION_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const conceptCard = page.locator("[data-testid='skills-concept-card-agent']");
|
||||
await expect(conceptCard).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("grid shows 42 skill cards when filter is 'all'", async ({ page }) => {
|
||||
const skills = makeAgentSkills();
|
||||
|
||||
await page.route(/\/api\/agent-skills(?:\?.*)?$/, async (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
await fulfillJson(route, { skills, coverage: FULL_COVERAGE });
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/agent-skills", {
|
||||
timeoutMs: NAVIGATION_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
// Wait for cards to render
|
||||
await expect(page.locator("[data-testid^='skill-card-']").first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
const cards = page.locator("[data-testid^='skill-card-']");
|
||||
await expect(cards).toHaveCount(42, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("clicking omni-skill-0 card renders markdown in preview pane", async ({
|
||||
page,
|
||||
}) => {
|
||||
const skills = makeAgentSkills();
|
||||
const mockMarkdown = "# API Skill 0\n\nThis skill manages connections.";
|
||||
|
||||
await page.route(/\/api\/agent-skills(?:\?.*)?$/, async (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
await fulfillJson(route, { skills, coverage: FULL_COVERAGE });
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.route(/\/api\/agent-skills\/omni-skill-0\/raw/, async (route) => {
|
||||
await fulfillText(route, mockMarkdown);
|
||||
});
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/agent-skills", {
|
||||
timeoutMs: NAVIGATION_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
// Wait for cards to render and click first card
|
||||
const firstCard = page.locator("[data-testid='skill-card-omni-skill-0']");
|
||||
await expect(firstCard).toBeVisible({ timeout: 15_000 });
|
||||
await firstCard.click();
|
||||
|
||||
// Preview pane should show non-empty content
|
||||
const previewPane = page.locator("[data-testid='skill-preview-pane']");
|
||||
await expect(previewPane).toBeVisible({ timeout: 15_000 });
|
||||
const previewText = await previewPane.textContent();
|
||||
expect(previewText?.trim().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("cross-link 'Understand the difference' navigates to /dashboard/omni-skills", async ({
|
||||
page,
|
||||
}) => {
|
||||
const skills = makeAgentSkills();
|
||||
|
||||
await page.route(/\/api\/agent-skills(?:\?.*)?$/, async (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
await fulfillJson(route, { skills, coverage: FULL_COVERAGE });
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/agent-skills", {
|
||||
timeoutMs: NAVIGATION_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const conceptCard = page.locator("[data-testid='skills-concept-card-agent']");
|
||||
await expect(conceptCard).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Find the cross-link inside the concept card
|
||||
const crossLink = conceptCard.locator("a");
|
||||
await expect(crossLink).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
await crossLink.click();
|
||||
await page.waitForURL(/\/dashboard\/omni-skills/, { timeout: 15_000 });
|
||||
expect(page.url()).toContain("/dashboard/omni-skills");
|
||||
});
|
||||
|
||||
test("/dashboard/skills redirects to /dashboard/omni-skills", async ({ page }) => {
|
||||
await page.goto("/dashboard/skills", { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS });
|
||||
// Next.js redirects /dashboard/skills → /dashboard/omni-skills (next.config.mjs).
|
||||
// If auth is required the app then client-redirects to /login (bare path, no /dashboard/ prefix).
|
||||
await page.waitForURL(/\/(login|onboarding|dashboard\/(omni-skills|onboarding))/, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
const finalUrl = page.url();
|
||||
expect(
|
||||
finalUrl.includes("/dashboard/omni-skills") ||
|
||||
finalUrl.includes("/login") ||
|
||||
finalUrl.includes("/onboarding"),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
|
||||
|
||||
function getTimeRangeSelector(page: import("@playwright/test").Page) {
|
||||
return page.getByRole("tablist", { name: /select time range/i }).first();
|
||||
}
|
||||
|
||||
async function waitForAnalyticsShell(page: import("@playwright/test").Page) {
|
||||
const mainTabList = page.locator('[role="tablist"]').first();
|
||||
await expect(mainTabList).toBeVisible({ timeout: 15000 });
|
||||
await expect(
|
||||
page
|
||||
.locator("button")
|
||||
.filter({
|
||||
hasText: /overview/i,
|
||||
})
|
||||
.first()
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
}
|
||||
|
||||
test.describe("Analytics Tabs UI", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route("**/api/usage/analytics", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
stats: {
|
||||
totalRequests: 1234,
|
||||
totalTokens: 567890,
|
||||
estimatedCost: 12.34,
|
||||
},
|
||||
charts: {},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/usage/utilization**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
timeRange: "24h",
|
||||
bucketSizeMinutes: 10,
|
||||
providers: ["codex", "claude", "gemini"],
|
||||
data: [
|
||||
{
|
||||
timestamp: new Date(Date.now() - 3600000).toISOString(),
|
||||
provider: "codex",
|
||||
remainingPct: 75.5,
|
||||
isExhausted: false,
|
||||
windowKey: "5h",
|
||||
},
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
provider: "codex",
|
||||
remainingPct: 72.0,
|
||||
isExhausted: false,
|
||||
windowKey: "5h",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/usage/combo-health**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
timeRange: "24h",
|
||||
combo: {
|
||||
id: "test-combo",
|
||||
name: "Test Combo",
|
||||
models: ["openai/gpt-4", "anthropic/claude-3"],
|
||||
},
|
||||
quotaHealth: {
|
||||
overall: {
|
||||
remainingAvg: 68.5,
|
||||
trend: "stable",
|
||||
},
|
||||
providers: [
|
||||
{
|
||||
provider: "openai",
|
||||
remaining: 75.0,
|
||||
trend: "stable",
|
||||
},
|
||||
],
|
||||
},
|
||||
usageSkew: {
|
||||
modelDistribution: [
|
||||
{ model: "openai/gpt-4", count: 45, share: 0.6 },
|
||||
{ model: "anthropic/claude-3", count: 30, share: 0.4 },
|
||||
],
|
||||
gini: 0.2,
|
||||
},
|
||||
performance: {
|
||||
successRate: 0.98,
|
||||
avgLatency: 1200,
|
||||
totalCalls: 75,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/combos", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
combos: [
|
||||
{
|
||||
id: "test-combo",
|
||||
name: "Test Combo",
|
||||
models: ["openai/gpt-4", "anthropic/claude-3"],
|
||||
strategy: "priority",
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("displays all 5 analytics tabs", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/analytics");
|
||||
await waitForAnalyticsShell(page);
|
||||
|
||||
const mainTabList = page.locator('[role="tablist"]').first();
|
||||
await expect(mainTabList).toBeVisible();
|
||||
|
||||
const tabButtons = page.locator(
|
||||
'button[class*="segmented"], [role="tablist"] > button, [role="tablist"] div > button'
|
||||
);
|
||||
const count = await tabButtons.count();
|
||||
expect(count).toBeGreaterThanOrEqual(4);
|
||||
|
||||
const tabLabels = ["overview", "evals", "search", "utilization", "combo health"];
|
||||
for (const label of tabLabels) {
|
||||
const tabButton = page
|
||||
.locator("button")
|
||||
.filter({
|
||||
hasText: new RegExp(label, "i"),
|
||||
})
|
||||
.first();
|
||||
await expect(tabButton).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("Provider Utilization tab shows TimeRangeSelector and chart", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/analytics");
|
||||
await waitForAnalyticsShell(page);
|
||||
|
||||
const utilizationTab = page
|
||||
.locator("button")
|
||||
.filter({
|
||||
hasText: /utilization/i,
|
||||
})
|
||||
.first();
|
||||
|
||||
// Retry click until the tab switches, mitigating Next.js hydration race conditions
|
||||
await expect(async () => {
|
||||
await utilizationTab.click();
|
||||
const timeRangeSelector = page
|
||||
.locator('[role="tablist"][aria-label]')
|
||||
.filter({ hasText: /1h/ })
|
||||
.first();
|
||||
await expect(timeRangeSelector).toBeVisible({ timeout: 1000 });
|
||||
}).toPass({ timeout: 15000 });
|
||||
|
||||
const timeRangeSelector = page
|
||||
.locator('[role="tablist"][aria-label]')
|
||||
.filter({ hasText: /1h/ })
|
||||
.first();
|
||||
|
||||
const timeRangeButtons = timeRangeSelector.locator('button[role="tab"]');
|
||||
await expect(timeRangeButtons.first()).toBeVisible();
|
||||
|
||||
const chart = page
|
||||
.locator('svg.recharts-surface, .recharts-wrapper, div[class*="recharts"]')
|
||||
.first();
|
||||
await expect(chart).toBeVisible();
|
||||
});
|
||||
|
||||
test("Combo Health tab displays health cards and metrics", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/analytics");
|
||||
await waitForAnalyticsShell(page);
|
||||
|
||||
const comboHealthTab = page
|
||||
.locator("button")
|
||||
.filter({
|
||||
hasText: /combo.*health/i,
|
||||
})
|
||||
.first();
|
||||
|
||||
await expect(async () => {
|
||||
await comboHealthTab.click();
|
||||
const timeRangeSelector = getTimeRangeSelector(page);
|
||||
await expect(timeRangeSelector).toBeVisible({ timeout: 1000 });
|
||||
}).toPass({ timeout: 15000 });
|
||||
|
||||
const mainContent = page.locator('main, [class*="dashboard"], div[class*="container"]').first();
|
||||
await expect(mainContent).toBeVisible();
|
||||
|
||||
const timeRangeSelector = getTimeRangeSelector(page);
|
||||
await expect(timeRangeSelector).toBeVisible();
|
||||
|
||||
const metricElements = page
|
||||
.locator('[class*="rounded-lg"], [class*="border"], [class*="bg-black/"]')
|
||||
.first();
|
||||
await expect(metricElements).toBeVisible();
|
||||
});
|
||||
|
||||
test("time range change triggers network request", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/analytics");
|
||||
await waitForAnalyticsShell(page);
|
||||
|
||||
const utilizationTab = page
|
||||
.locator("button")
|
||||
.filter({
|
||||
hasText: /utilization/i,
|
||||
})
|
||||
.first();
|
||||
|
||||
await expect(async () => {
|
||||
await utilizationTab.click();
|
||||
const timeRangeSelector = getTimeRangeSelector(page);
|
||||
await expect(timeRangeSelector).toBeVisible({ timeout: 1000 });
|
||||
}).toPass({ timeout: 15000 });
|
||||
|
||||
let networkRequestMade = false;
|
||||
page.on("request", (request) => {
|
||||
if (request.url().includes("/api/usage/utilization")) {
|
||||
const url = new URL(request.url());
|
||||
if (url.searchParams.get("range") === "7d") {
|
||||
networkRequestMade = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const timeRangeSelector = getTimeRangeSelector(page);
|
||||
const sevenDayButton = timeRangeSelector
|
||||
.locator('button[role="tab"]')
|
||||
.filter({ hasText: "7d" })
|
||||
.first();
|
||||
|
||||
if (await sevenDayButton.isVisible()) {
|
||||
await sevenDayButton.click();
|
||||
} else {
|
||||
const buttons = timeRangeSelector.locator('button[role="tab"]');
|
||||
const count = await buttons.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const button = buttons.nth(i);
|
||||
const text = await button.textContent();
|
||||
if (text?.includes("7")) {
|
||||
await button.click();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect
|
||||
.poll(() => networkRequestMade, {
|
||||
message: "Expected time range change to trigger network request",
|
||||
})
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test("tab switching persists state correctly", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/analytics");
|
||||
await waitForAnalyticsShell(page);
|
||||
|
||||
const overviewTab = page
|
||||
.locator("button")
|
||||
.filter({
|
||||
hasText: /overview/i,
|
||||
})
|
||||
.first();
|
||||
|
||||
await expect(async () => {
|
||||
await overviewTab.click();
|
||||
const overviewStats = page.locator("text=Total API Requests").first();
|
||||
// Overview uses UsageAnalytics, we wait for a generic evidence of overview
|
||||
// Or simply just wait 300ms if click doesn't throw
|
||||
})
|
||||
.toPass({ timeout: 15000 })
|
||||
.catch(() => {});
|
||||
|
||||
const utilizationTab = page
|
||||
.locator("button")
|
||||
.filter({
|
||||
hasText: /utilization/i,
|
||||
})
|
||||
.first();
|
||||
|
||||
await expect(async () => {
|
||||
await utilizationTab.click();
|
||||
const chart = page
|
||||
.locator('svg.recharts-surface, .recharts-wrapper, div[class*="recharts"]')
|
||||
.first();
|
||||
await expect(chart).toBeVisible({ timeout: 1000 });
|
||||
}).toPass({ timeout: 15000 });
|
||||
|
||||
const chart = page
|
||||
.locator('svg.recharts-surface, .recharts-wrapper, div[class*="recharts"]')
|
||||
.first();
|
||||
await expect(chart).toBeVisible();
|
||||
|
||||
const comboHealthTab = page
|
||||
.locator("button")
|
||||
.filter({
|
||||
hasText: /combo.*health/i,
|
||||
})
|
||||
.first();
|
||||
|
||||
await expect(async () => {
|
||||
await comboHealthTab.click();
|
||||
const timeRangeSelector = getTimeRangeSelector(page);
|
||||
await expect(timeRangeSelector).toBeVisible({ timeout: 1000 });
|
||||
}).toPass({ timeout: 15000 });
|
||||
|
||||
const timeRangeSelector = getTimeRangeSelector(page);
|
||||
await expect(timeRangeSelector).toBeVisible();
|
||||
|
||||
await expect(async () => {
|
||||
await utilizationTab.click();
|
||||
await expect(chart).toBeVisible({ timeout: 1000 });
|
||||
}).toPass({ timeout: 15000 });
|
||||
|
||||
await expect(chart).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,643 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
|
||||
|
||||
const NAVIGATION_TIMEOUT_MS = 300_000;
|
||||
const UI_STABILITY_TIMEOUT_MS = 120_000;
|
||||
|
||||
type ApiKeyRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string;
|
||||
fullKey: string;
|
||||
allowedModels: string[] | null;
|
||||
allowedConnections: string[] | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function installClipboardMock(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
let clipboardValue = "";
|
||||
Object.defineProperty(window, "__clipboardValue", {
|
||||
configurable: true,
|
||||
get: () => clipboardValue,
|
||||
set: (value) => {
|
||||
clipboardValue = typeof value === "string" ? value : String(value ?? "");
|
||||
},
|
||||
});
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: {
|
||||
writeText: async (value: string) => {
|
||||
(window as Window & { __clipboardValue?: string }).__clipboardValue = value;
|
||||
},
|
||||
readText: async () => clipboardValue,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function readClipboard(page: Page) {
|
||||
return page.evaluate(() => (window as Window & { __clipboardValue?: string }).__clipboardValue);
|
||||
}
|
||||
|
||||
async function waitForPageToSettle(page: Page) {
|
||||
try {
|
||||
await page.waitForLoadState("networkidle", { timeout: 15_000 });
|
||||
} catch {
|
||||
// Some dashboard pages keep background requests alive; visibility assertions below
|
||||
// are the authoritative readiness check for these E2E flows.
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForNextDevCompileToFinish(page: Page) {
|
||||
const nextDevToolsButton = page.getByRole("button", { name: /open next\.js dev tools/i });
|
||||
if ((await nextDevToolsButton.count()) === 0) return;
|
||||
await expect(nextDevToolsButton).not.toContainText(/compiling/i, { timeout: 120_000 });
|
||||
}
|
||||
|
||||
test.describe("API keys flow", () => {
|
||||
test.setTimeout(600_000);
|
||||
|
||||
test("creates, copies, reveals, revokes, and returns to the empty state", async ({ page }) => {
|
||||
const state: {
|
||||
keys: ApiKeyRecord[];
|
||||
nextId: number;
|
||||
revealCalls: number;
|
||||
deleteCalls: number;
|
||||
} = {
|
||||
keys: [],
|
||||
nextId: 1,
|
||||
revealCalls: 0,
|
||||
deleteCalls: 0,
|
||||
};
|
||||
|
||||
await installClipboardMock(page);
|
||||
|
||||
await page.route("**/v1/models", async (route) => {
|
||||
await fulfillJson(route, { data: [] });
|
||||
});
|
||||
|
||||
await page.route("**/api/settings", async (route) => {
|
||||
await fulfillJson(route, {});
|
||||
});
|
||||
|
||||
await page.route("**/api/providers", async (route) => {
|
||||
await fulfillJson(route, {
|
||||
connections: [
|
||||
{ id: "conn-openai", name: "OpenAI Main", provider: "openai", isActive: true },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(/\/api\/usage\/call-logs(?:\?.*)?$/, async (route) => {
|
||||
await fulfillJson(route, []);
|
||||
});
|
||||
|
||||
await page.route("**/api/sessions", async (route) => {
|
||||
await fulfillJson(route, { byApiKey: {} });
|
||||
});
|
||||
|
||||
await page.route(/\/api\/keys\/[^/]+\/reveal$/, async (route) => {
|
||||
state.revealCalls += 1;
|
||||
const keyId = route.request().url().split("/").slice(-2)[0];
|
||||
const record = state.keys.find((key) => key.id === keyId);
|
||||
await fulfillJson(route, { key: record?.fullKey ?? "" });
|
||||
});
|
||||
|
||||
await page.route(/\/api\/keys\/[^/]+$/, async (route) => {
|
||||
if (route.request().method() === "DELETE") {
|
||||
state.deleteCalls += 1;
|
||||
const keyId = route.request().url().split("/").pop() || "";
|
||||
state.keys = state.keys.filter((key) => key.id !== keyId);
|
||||
await fulfillJson(route, { success: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (route.request().method() === "PATCH") {
|
||||
const keyId = route.request().url().split("/").pop() || "";
|
||||
const payload = (await route.request().postDataJSON()) as { name?: string };
|
||||
const record = state.keys.find((key) => key.id === keyId);
|
||||
if (!record) {
|
||||
await fulfillJson(route, { error: "Key not found" }, 404);
|
||||
return;
|
||||
}
|
||||
if (payload.name) {
|
||||
record.name = payload.name;
|
||||
}
|
||||
await fulfillJson(route, { message: "API key settings updated successfully", ...payload });
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(route, { error: "Method not allowed in api key detail stub" }, 405);
|
||||
});
|
||||
|
||||
await page.route("**/api/keys", async (route) => {
|
||||
const method = route.request().method();
|
||||
|
||||
if (method === "GET") {
|
||||
await fulfillJson(route, {
|
||||
keys: state.keys.map(({ fullKey, ...record }) => record),
|
||||
allowKeyReveal: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST") {
|
||||
const payload = (route.request().postDataJSON() as { name?: string }) || {};
|
||||
const id = `key-${state.nextId++}`;
|
||||
const suffix = String(1000 + state.nextId);
|
||||
const fullKey = `sk-live-${suffix}-demo-secret`;
|
||||
const maskedKey = `sk-live-****${suffix}`;
|
||||
state.keys.push({
|
||||
id,
|
||||
name: payload.name || "New Key",
|
||||
key: maskedKey,
|
||||
fullKey,
|
||||
allowedModels: null,
|
||||
allowedConnections: null,
|
||||
createdAt: new Date("2026-04-05T20:00:00.000Z").toISOString(),
|
||||
});
|
||||
|
||||
await fulfillJson(route, { key: fullKey, id });
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(route, { error: "Method not allowed in api keys stub" }, 405);
|
||||
});
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/api-manager", {
|
||||
timeoutMs: NAVIGATION_TIMEOUT_MS,
|
||||
});
|
||||
await waitForPageToSettle(page);
|
||||
await waitForNextDevCompileToFinish(page);
|
||||
|
||||
const createFirstKeyButton = page.getByRole("button", {
|
||||
name: /create (your )?first key/i,
|
||||
});
|
||||
await expect(createFirstKeyButton).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS });
|
||||
await waitForPageToSettle(page);
|
||||
await waitForNextDevCompileToFinish(page);
|
||||
await createFirstKeyButton.click();
|
||||
|
||||
const createDialog = page.getByRole("dialog", { name: /create api key/i });
|
||||
await expect(createDialog).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS });
|
||||
await createDialog.locator("input").first().fill("Team Key");
|
||||
const createKeyButton = createDialog.getByRole("button", { name: /create api key/i });
|
||||
await expect(createKeyButton).toBeEnabled({ timeout: UI_STABILITY_TIMEOUT_MS });
|
||||
await createKeyButton.click({ force: true });
|
||||
await expect.poll(() => state.keys.length).toBe(1);
|
||||
|
||||
const createdDialog = page.getByRole("dialog", { name: /api key created/i });
|
||||
const createdKeyInput = createdDialog.locator("input[readonly]").first();
|
||||
await expect(createdKeyInput).toHaveValue(/sk-live-/);
|
||||
await createdDialog.getByRole("button", { name: /copy/i }).click();
|
||||
|
||||
await expect.poll(() => readClipboard(page)).toBeTruthy();
|
||||
const createdClipboardValue = await readClipboard(page);
|
||||
await expect(createdKeyInput).toHaveValue(createdClipboardValue || "");
|
||||
|
||||
await createdDialog.getByRole("button", { name: /done/i }).click();
|
||||
|
||||
await expect(page.getByText("Team Key")).toBeVisible();
|
||||
await expect(page.getByText("sk-live-****1002")).toBeVisible();
|
||||
|
||||
const keyRow = page
|
||||
.locator("div")
|
||||
.filter({ has: page.getByText("Team Key", { exact: true }) })
|
||||
.filter({ has: page.getByText("sk-live-****1002", { exact: true }) })
|
||||
.first();
|
||||
|
||||
await keyRow.getByRole("button", { name: /copy/i }).click();
|
||||
await expect.poll(() => state.revealCalls).toBe(1);
|
||||
await expect.poll(() => readClipboard(page)).toBe("sk-live-1002-demo-secret");
|
||||
|
||||
page.once("dialog", async (dialog) => {
|
||||
await dialog.accept();
|
||||
});
|
||||
await keyRow.locator("button[title]").last().click({ force: true });
|
||||
|
||||
await expect.poll(() => state.deleteCalls).toBe(1);
|
||||
await expect(page.getByText("Team Key")).toHaveCount(0);
|
||||
await expect(createFirstKeyButton).toBeVisible();
|
||||
});
|
||||
|
||||
test("renames a key through the permissions modal", async ({ page }) => {
|
||||
const state: {
|
||||
keys: ApiKeyRecord[];
|
||||
nextId: number;
|
||||
} = {
|
||||
keys: [],
|
||||
nextId: 1,
|
||||
};
|
||||
|
||||
await page.route("**/v1/models", async (route) => {
|
||||
await fulfillJson(route, { data: [] });
|
||||
});
|
||||
|
||||
await page.route("**/api/settings", async (route) => {
|
||||
await fulfillJson(route, {});
|
||||
});
|
||||
|
||||
await page.route("**/api/providers", async (route) => {
|
||||
await fulfillJson(route, {
|
||||
connections: [
|
||||
{ id: "conn-openai", name: "OpenAI Main", provider: "openai", isActive: true },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(/\/api\/usage\/call-logs(?:\?.*)?$/, async (route) => {
|
||||
await fulfillJson(route, []);
|
||||
});
|
||||
|
||||
await page.route("**/api/sessions", async (route) => {
|
||||
await fulfillJson(route, { byApiKey: {} });
|
||||
});
|
||||
|
||||
await page.route(/\/api\/keys\/[^/]+$/, async (route) => {
|
||||
if (route.request().method() === "PATCH") {
|
||||
const keyId = route.request().url().split("/").pop() || "";
|
||||
const payload = (await route.request().postDataJSON()) as { name?: string };
|
||||
const record = state.keys.find((key) => key.id === keyId);
|
||||
if (!record) {
|
||||
await fulfillJson(route, { error: "Key not found" }, 404);
|
||||
return;
|
||||
}
|
||||
if (payload.name) {
|
||||
record.name = payload.name;
|
||||
}
|
||||
await fulfillJson(route, { message: "API key settings updated successfully", ...payload });
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(route, { error: "Method not allowed" }, 405);
|
||||
});
|
||||
|
||||
await page.route("**/api/keys", async (route) => {
|
||||
const method = route.request().method();
|
||||
|
||||
if (method === "GET") {
|
||||
await fulfillJson(route, {
|
||||
keys: state.keys.map(({ fullKey, ...record }) => record),
|
||||
allowKeyReveal: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST") {
|
||||
const payload = (route.request().postDataJSON() as { name?: string }) || {};
|
||||
const id = `key-${state.nextId++}`;
|
||||
const suffix = String(1000 + state.nextId);
|
||||
const fullKey = `sk-live-${suffix}-demo-secret`;
|
||||
const maskedKey = `sk-live-****${suffix}`;
|
||||
state.keys.push({
|
||||
id,
|
||||
name: payload.name || "New Key",
|
||||
key: maskedKey,
|
||||
fullKey,
|
||||
allowedModels: null,
|
||||
allowedConnections: null,
|
||||
createdAt: new Date("2026-04-05T20:00:00.000Z").toISOString(),
|
||||
});
|
||||
|
||||
await fulfillJson(route, { key: fullKey, id });
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(route, { error: "Method not allowed" }, 405);
|
||||
});
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/api-manager", {
|
||||
timeoutMs: NAVIGATION_TIMEOUT_MS,
|
||||
});
|
||||
await waitForPageToSettle(page);
|
||||
await waitForNextDevCompileToFinish(page);
|
||||
|
||||
// --- Create a key ---
|
||||
const createFirstKeyButton = page.getByRole("button", {
|
||||
name: /create (your )?first key/i,
|
||||
});
|
||||
await expect(createFirstKeyButton).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS });
|
||||
await waitForPageToSettle(page);
|
||||
await waitForNextDevCompileToFinish(page);
|
||||
await createFirstKeyButton.click();
|
||||
|
||||
const createDialog = page.getByRole("dialog", { name: /create api key/i });
|
||||
await expect(createDialog).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS });
|
||||
await createDialog.locator("input").first().fill("Original Name");
|
||||
const createKeyButton = createDialog.getByRole("button", { name: /create api key/i });
|
||||
await expect(createKeyButton).toBeEnabled({ timeout: UI_STABILITY_TIMEOUT_MS });
|
||||
await createKeyButton.click({ force: true });
|
||||
await expect.poll(() => state.keys.length).toBe(1);
|
||||
|
||||
// Dismiss the "key created" dialog
|
||||
const createdDialog = page.getByRole("dialog", { name: /api key created/i });
|
||||
await createdDialog.getByRole("button", { name: /done/i }).click();
|
||||
|
||||
// Wait for the key list to settle after re-fetch
|
||||
await waitForPageToSettle(page);
|
||||
await waitForNextDevCompileToFinish(page);
|
||||
|
||||
// Verify the key appears in the list
|
||||
await expect(page.getByText("Original Name")).toBeVisible({
|
||||
timeout: UI_STABILITY_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
// --- Open the permissions modal ---
|
||||
// The edit-permissions button is opacity-0 until hover; use title attribute locator
|
||||
const keyRow = page
|
||||
.locator("div")
|
||||
.filter({ has: page.getByText("Original Name", { exact: true }) })
|
||||
.first();
|
||||
await keyRow.locator('button[title="Edit permissions"]').click({ force: true });
|
||||
|
||||
// --- Rename the key ---
|
||||
const permissionsDialog = page.getByRole("dialog", { name: /permissions: original name/i });
|
||||
await expect(permissionsDialog).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS });
|
||||
|
||||
// The key name input is inside the permissions modal
|
||||
const nameInput = permissionsDialog.locator("input").first();
|
||||
await expect(nameInput).toHaveValue("Original Name");
|
||||
await nameInput.clear();
|
||||
await nameInput.fill("Renamed Key");
|
||||
|
||||
// Save
|
||||
await permissionsDialog
|
||||
.getByRole("button", { name: /save permissions/i })
|
||||
.click({ force: true });
|
||||
|
||||
// Verify the mock state was updated
|
||||
await expect.poll(() => state.keys[0]?.name).toBe("Renamed Key");
|
||||
|
||||
// Verify the dialog closes and the list reflects the new name
|
||||
await expect(permissionsDialog).not.toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS });
|
||||
await expect(page.getByText("Renamed Key")).toBeVisible();
|
||||
});
|
||||
|
||||
test("validation error appears inside the create key modal, not behind the backdrop", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = {
|
||||
keys: [] as ApiKeyRecord[],
|
||||
nextId: 1,
|
||||
};
|
||||
|
||||
// Stub all API routes
|
||||
await page.route("**/v1/models", async (route) => {
|
||||
await fulfillJson(route, { data: [] });
|
||||
});
|
||||
await page.route("**/api/connections", async (route) => {
|
||||
await fulfillJson(route, {
|
||||
connections: [
|
||||
{ id: "conn-openai", name: "OpenAI Main", provider: "openai", isActive: true },
|
||||
],
|
||||
});
|
||||
});
|
||||
await page.route(/\/api\/usage\/call-logs(?:\?.*)?$/, async (route) => {
|
||||
await fulfillJson(route, []);
|
||||
});
|
||||
await page.route("**/api/sessions", async (route) => {
|
||||
await fulfillJson(route, { byApiKey: {} });
|
||||
});
|
||||
await page.route("**/api/keys", async (route) => {
|
||||
const method = route.request().method();
|
||||
if (method === "GET") {
|
||||
await fulfillJson(route, {
|
||||
keys: state.keys.map(({ fullKey, ...record }) => record),
|
||||
allowKeyReveal: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (method === "POST") {
|
||||
const payload = (route.request().postDataJSON() as { name?: string }) || {};
|
||||
const id = `key-${state.nextId++}`;
|
||||
const suffix = String(1000 + state.nextId);
|
||||
const fullKey = `sk-live-${suffix}-demo-secret`;
|
||||
const maskedKey = `sk-live-****${suffix}`;
|
||||
state.keys.push({
|
||||
id,
|
||||
name: payload.name || "New Key",
|
||||
key: maskedKey,
|
||||
fullKey,
|
||||
allowedModels: null,
|
||||
allowedConnections: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
await fulfillJson(route, { key: fullKey, id });
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, { error: "Method not allowed" }, 405);
|
||||
});
|
||||
await page.route(/\/api\/keys\/[^/]+$/, async (route) => {
|
||||
await fulfillJson(route, { error: "Not found" }, 404);
|
||||
});
|
||||
await page.route(/\/api\/keys\/[^/]+\/reveal$/, async (route) => {
|
||||
await fulfillJson(route, { key: "" });
|
||||
});
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/api-manager", {
|
||||
timeoutMs: NAVIGATION_TIMEOUT_MS,
|
||||
});
|
||||
await waitForPageToSettle(page);
|
||||
await waitForNextDevCompileToFinish(page);
|
||||
|
||||
// Open the create key modal
|
||||
const createFirstKeyButton = page.getByRole("button", {
|
||||
name: /create (your )?first key/i,
|
||||
});
|
||||
await expect(createFirstKeyButton).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS });
|
||||
await createFirstKeyButton.click();
|
||||
|
||||
const createDialog = page.getByRole("dialog", { name: /create api key/i });
|
||||
await expect(createDialog).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS });
|
||||
|
||||
// Fill an invalid name (special characters) and submit
|
||||
const nameInput = createDialog.locator("input").first();
|
||||
await nameInput.fill("bad@name!");
|
||||
const createKeyButton = createDialog.getByRole("button", { name: /create api key/i });
|
||||
await createKeyButton.click({ force: true });
|
||||
|
||||
// The validation error must appear as an inline <p role="alert"> INSIDE the modal
|
||||
const inlineError = createDialog.locator("p[role='alert']");
|
||||
await expect(inlineError).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS });
|
||||
|
||||
// The error must be visible — i.e. not obscured by the backdrop.
|
||||
// A covered element has box visibility but fails the check below because
|
||||
// pointer events and paint are blocked by the overlay div.
|
||||
await expect(inlineError).toBeInViewport();
|
||||
|
||||
// No page-level error banner should appear behind the modal
|
||||
const pageErrorBanner = page.locator('.flex.flex-col.gap-8 > [class~="bg-red-500/10"]');
|
||||
await expect(pageErrorBanner).not.toBeVisible();
|
||||
|
||||
// Typing should clear the error
|
||||
await nameInput.fill("valid-name");
|
||||
await expect(inlineError).not.toBeVisible();
|
||||
|
||||
// A valid name should succeed without errors
|
||||
await createKeyButton.click({ force: true });
|
||||
await expect.poll(() => state.keys.length).toBe(1);
|
||||
});
|
||||
|
||||
test("validation error appears inside the permissions modal when renaming, not behind the backdrop", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state: {
|
||||
keys: ApiKeyRecord[];
|
||||
nextId: number;
|
||||
} = {
|
||||
keys: [],
|
||||
nextId: 1,
|
||||
};
|
||||
|
||||
// Stub all API routes
|
||||
await page.route("**/v1/models", async (route) => {
|
||||
await fulfillJson(route, { data: [] });
|
||||
});
|
||||
|
||||
await page.route("**/api/settings", async (route) => {
|
||||
await fulfillJson(route, {});
|
||||
});
|
||||
|
||||
await page.route("**/api/providers", async (route) => {
|
||||
await fulfillJson(route, {
|
||||
connections: [
|
||||
{ id: "conn-openai", name: "OpenAI Main", provider: "openai", isActive: true },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(/\/api\/usage\/call-logs(?:\?.*)?$/, async (route) => {
|
||||
await fulfillJson(route, []);
|
||||
});
|
||||
|
||||
await page.route("**/api/sessions", async (route) => {
|
||||
await fulfillJson(route, { byApiKey: {} });
|
||||
});
|
||||
|
||||
await page.route(/\/api\/keys\/[^/]+$/, async (route) => {
|
||||
if (route.request().method() === "PATCH") {
|
||||
const keyId = route.request().url().split("/").pop() || "";
|
||||
const payload = (await route.request().postDataJSON()) as { name?: string };
|
||||
const record = state.keys.find((key) => key.id === keyId);
|
||||
if (!record) {
|
||||
await fulfillJson(route, { error: "Key not found" }, 404);
|
||||
return;
|
||||
}
|
||||
if (payload.name) {
|
||||
record.name = payload.name;
|
||||
}
|
||||
await fulfillJson(route, { message: "API key settings updated successfully", ...payload });
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(route, { error: "Method not allowed" }, 405);
|
||||
});
|
||||
|
||||
await page.route("**/api/keys", async (route) => {
|
||||
const method = route.request().method();
|
||||
|
||||
if (method === "GET") {
|
||||
await fulfillJson(route, {
|
||||
keys: state.keys.map(({ fullKey, ...record }) => record),
|
||||
allowKeyReveal: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST") {
|
||||
const payload = (route.request().postDataJSON() as { name?: string }) || {};
|
||||
const id = `key-${state.nextId++}`;
|
||||
const suffix = String(1000 + state.nextId);
|
||||
const fullKey = `sk-live-${suffix}-demo-secret`;
|
||||
const maskedKey = `sk-live-****${suffix}`;
|
||||
state.keys.push({
|
||||
id,
|
||||
name: payload.name || "New Key",
|
||||
key: maskedKey,
|
||||
fullKey,
|
||||
allowedModels: null,
|
||||
allowedConnections: null,
|
||||
createdAt: new Date("2026-04-05T20:00:00.000Z").toISOString(),
|
||||
});
|
||||
|
||||
await fulfillJson(route, { key: fullKey, id });
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(route, { error: "Method not allowed" }, 405);
|
||||
});
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/api-manager", {
|
||||
timeoutMs: NAVIGATION_TIMEOUT_MS,
|
||||
});
|
||||
await waitForPageToSettle(page);
|
||||
await waitForNextDevCompileToFinish(page);
|
||||
|
||||
// Create a key first
|
||||
const createFirstKeyButton = page.getByRole("button", {
|
||||
name: /create (your )?first key/i,
|
||||
});
|
||||
await expect(createFirstKeyButton).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS });
|
||||
await createFirstKeyButton.click();
|
||||
|
||||
const createDialog = page.getByRole("dialog", { name: /create api key/i });
|
||||
await expect(createDialog).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS });
|
||||
await createDialog.locator("input").first().fill("Original Name");
|
||||
await createDialog.getByRole("button", { name: /create api key/i }).click({ force: true });
|
||||
|
||||
// Dismiss the created dialog
|
||||
const createdDialog = page.getByRole("dialog", { name: /api key created/i });
|
||||
await createdDialog.getByRole("button", { name: /done/i }).click();
|
||||
await waitForPageToSettle(page);
|
||||
await waitForNextDevCompileToFinish(page);
|
||||
await expect(page.getByText("Original Name")).toBeVisible({
|
||||
timeout: UI_STABILITY_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
// Open the permissions modal
|
||||
const keyRow = page
|
||||
.locator("div")
|
||||
.filter({ has: page.getByText("Original Name", { exact: true }) })
|
||||
.first();
|
||||
await keyRow.locator('button[title="Edit permissions"]').click({ force: true });
|
||||
|
||||
const permissionsDialog = page.getByRole("dialog", {
|
||||
name: /permissions: original name/i,
|
||||
});
|
||||
await expect(permissionsDialog).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS });
|
||||
|
||||
// --- Try to save with an invalid name (empty) ---
|
||||
const nameInput = permissionsDialog.locator("input").first();
|
||||
await nameInput.clear();
|
||||
await nameInput.fill(" ");
|
||||
const saveButton = permissionsDialog.getByRole("button", { name: /save permissions/i });
|
||||
await saveButton.click({ force: true });
|
||||
|
||||
// Inline error should appear inside the permissions modal
|
||||
const inlineError = permissionsDialog.locator('[id$="-error"]');
|
||||
await expect(inlineError).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS });
|
||||
|
||||
// No page-level error banner should appear behind the modal
|
||||
const pageErrorBanner = page.locator('.flex.flex-col.gap-8 > [class~="bg-red-500/10"]');
|
||||
await expect(pageErrorBanner).not.toBeVisible();
|
||||
|
||||
// Typing a valid name should clear the error
|
||||
await nameInput.fill("Renamed Key");
|
||||
await expect(inlineError).not.toBeVisible();
|
||||
|
||||
// Save should succeed now
|
||||
await saveButton.click({ force: true });
|
||||
await expect.poll(() => state.keys[0]?.name).toBe("Renamed Key");
|
||||
await expect(permissionsDialog).not.toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS });
|
||||
await expect(page.getByText("Renamed Key")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("API Health Checks", () => {
|
||||
test("GET /api/monitoring/health returns OK", async ({ request }) => {
|
||||
const res = await request.get("/api/monitoring/health");
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const body = (await res.json()) as any;
|
||||
expect(body).toHaveProperty("status");
|
||||
});
|
||||
|
||||
test("GET /api/v1/models returns model list", async ({ request }) => {
|
||||
const res = await request.get("/api/v1/models");
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const body = (await res.json()) as any;
|
||||
expect(body).toHaveProperty("data");
|
||||
expect(Array.isArray(body.data)).toBe(true);
|
||||
});
|
||||
|
||||
test("GET /api/providers returns provider list or requires auth", async ({ request }) => {
|
||||
const res = await request.get("/api/providers");
|
||||
// In CI with auth enabled, 401 is acceptable — endpoint is reachable
|
||||
if (res.ok()) {
|
||||
const body = (await res.json()) as any;
|
||||
expect(body).toHaveProperty("connections");
|
||||
expect(Array.isArray(body.connections)).toBe(true);
|
||||
} else {
|
||||
expect([401, 403, 307]).toContain(res.status());
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
|
||||
|
||||
/**
|
||||
* F5.2 — Combo Live Studio (Tela B) smoke e2e.
|
||||
*
|
||||
* The cascade's per-target/per-provider logic is exhaustively unit-tested
|
||||
* (comboFlowModel reducer + enrich overlays) and the badges are vitest-covered
|
||||
* (ProviderCascadeNode). What unit tests CANNOT catch is a client-side render /
|
||||
* hydration crash on the real page — exactly the "no useTranslations trap on the
|
||||
* new page" risk the Tela B plan called out.
|
||||
*
|
||||
* This spec is that guard: it loads /dashboard/combos/live and asserts the studio
|
||||
* renders (cascade shell OR empty state). The visibility assertion IS the
|
||||
* hydration-trap guard — a `useTranslations`-outside-provider crash throws during
|
||||
* render, so neither testid would ever appear, and the page mounting at all proves
|
||||
* the U1b health-poll wiring (in the same client tree) loaded.
|
||||
*
|
||||
* Out of scope (kept unit-covered, to stay non-flaky): driving live combo cascades
|
||||
* (needs WS combo events an e2e cannot inject) and asserting console output
|
||||
* (dev-mode on-demand compilation emits transient fast-refresh noise the production
|
||||
* build CI runs against does not).
|
||||
*/
|
||||
test.describe("Combo Live Studio (Tela B)", () => {
|
||||
test("loads /dashboard/combos/live and renders the studio without crashing", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/combos/live");
|
||||
|
||||
// The studio always renders one of these — never a blank/crashed page.
|
||||
const studioOrEmpty = page
|
||||
.locator('[data-testid="combo-live-studio"]')
|
||||
.or(page.locator('[data-testid="combo-live-studio-empty"]'))
|
||||
.first();
|
||||
await expect(studioOrEmpty).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
|
||||
|
||||
async function mockCombosPageApis(page: import("@playwright/test").Page) {
|
||||
await page.route("**/api/combos", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
combos: [
|
||||
{
|
||||
id: "combo-auto",
|
||||
name: "combo-auto",
|
||||
models: ["openai/gpt-4o-mini"],
|
||||
strategy: "auto",
|
||||
config: { candidatePool: ["openai", "anthropic"], modePack: "ship-fast" },
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: "combo-priority",
|
||||
name: "combo-priority",
|
||||
models: ["anthropic/claude-sonnet-4-6"],
|
||||
strategy: "priority",
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/combos/metrics", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ metrics: {} }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/providers", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
connections: [
|
||||
{ id: "conn-openai", provider: "openai", name: "OpenAI", testStatus: "active" },
|
||||
{
|
||||
id: "conn-anthropic",
|
||||
provider: "anthropic",
|
||||
name: "Anthropic",
|
||||
testStatus: "active",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/provider-nodes", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ nodes: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/settings/proxy", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ combos: {} }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/monitoring/health", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
circuitBreakers: [
|
||||
{ provider: "openai", state: "CLOSED" },
|
||||
{ provider: "anthropic", state: "OPEN", lastFailure: new Date().toISOString() },
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function mockBuilderApis(page: import("@playwright/test").Page) {
|
||||
await page.route("**/api/models/alias", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ aliases: {} }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/pricing", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/settings/combo-defaults", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ comboDefaults: {} }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/combos/builder/options", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
providers: [
|
||||
{
|
||||
providerId: "openai",
|
||||
displayName: "OpenAI",
|
||||
connectionCount: 1,
|
||||
models: [{ id: "gpt-4o-mini", name: "gpt-4o-mini" }],
|
||||
connections: [{ id: "conn-openai", label: "OpenAI Main", status: "active" }],
|
||||
},
|
||||
],
|
||||
comboRefs: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Combo Unification", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockCombosPageApis(page);
|
||||
await mockBuilderApis(page);
|
||||
});
|
||||
|
||||
test("combos page exposes strategy tabs and intelligent panel", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/combos?filter=intelligent");
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator("button")
|
||||
.filter({ has: page.locator("span", { hasText: "layers" }) })
|
||||
.filter({ hasText: "All" })
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /intelligent/i })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /deterministic/i })).toBeVisible();
|
||||
await expect(page.getByText("Intelligent Routing Dashboard")).toBeVisible();
|
||||
await expect(page.getByText("Provider Scores")).toBeVisible();
|
||||
});
|
||||
|
||||
test("legacy auto-combo route redirects to intelligent combos filter", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/auto-combo");
|
||||
await page.waitForURL(/\/dashboard\/combos\?filter=intelligent/);
|
||||
await expect(page).toHaveURL(/\/dashboard\/combos\?filter=intelligent/);
|
||||
});
|
||||
|
||||
test("sidebar no longer shows auto combo entry", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/combos");
|
||||
|
||||
const sidebar = page.locator("aside, nav").first();
|
||||
await expect(sidebar.getByText("Combos", { exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByText("Auto Combo")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("builder shows intelligent step when auto strategy is selected", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/combos");
|
||||
|
||||
await page.getByRole("button", { name: /create combo/i }).click();
|
||||
await page.getByLabel(/combo name/i).waitFor({ state: "visible" });
|
||||
await page.getByLabel(/combo name/i).fill("e2e-auto-builder");
|
||||
await page.getByTestId("combo-builder-next").click();
|
||||
|
||||
await page.getByTestId("combo-builder-provider").waitFor({ state: "visible" });
|
||||
await page.getByTestId("combo-builder-provider").selectOption("openai");
|
||||
await page.getByTestId("combo-builder-model").waitFor({ state: "attached" });
|
||||
await page.getByTestId("combo-builder-model").selectOption("gpt-4o-mini");
|
||||
await page.getByTestId("combo-builder-add-step").click();
|
||||
await page.getByTestId("combo-builder-next").click();
|
||||
|
||||
await page.getByTestId("strategy-option-auto").waitFor({ state: "visible" });
|
||||
await page.getByTestId("strategy-option-auto").click();
|
||||
await page.getByTestId("combo-builder-next").click();
|
||||
|
||||
await expect(page.getByText("Candidate Pool", { exact: true })).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.getByText("Mode Pack", { exact: true })).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.getByText("Exploration Rate", { exact: true })).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,629 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
|
||||
|
||||
type ComboStub = {
|
||||
id: string;
|
||||
name: string;
|
||||
strategy: string;
|
||||
models: unknown[];
|
||||
config: Record<string, unknown>;
|
||||
isActive: boolean;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
type ComboCreatePayload = {
|
||||
name?: string;
|
||||
strategy?: string;
|
||||
models?: unknown[];
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
async function dispatchHtml5DragAndDrop(
|
||||
page: import("@playwright/test").Page,
|
||||
source: import("@playwright/test").Locator,
|
||||
target: import("@playwright/test").Locator
|
||||
) {
|
||||
const dataTransfer = await page.evaluateHandle(() => new DataTransfer());
|
||||
await source.dispatchEvent("dragstart", { dataTransfer });
|
||||
await target.dispatchEvent("dragenter", { dataTransfer });
|
||||
await target.dispatchEvent("dragover", { dataTransfer });
|
||||
await target.dispatchEvent("drop", { dataTransfer });
|
||||
await source.dispatchEvent("dragend", { dataTransfer });
|
||||
await dataTransfer.dispose();
|
||||
}
|
||||
|
||||
test.describe("Combos flow", () => {
|
||||
test("applies template, creates combo, and runs quick test CTA", async ({ page }) => {
|
||||
const state: {
|
||||
combos: ComboStub[];
|
||||
nextId: number;
|
||||
comboTestRequests: number;
|
||||
} = {
|
||||
combos: [],
|
||||
nextId: 1,
|
||||
comboTestRequests: 0,
|
||||
};
|
||||
|
||||
await page.route("**/api/combos/test", async (route) => {
|
||||
state.comboTestRequests += 1;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
resolvedBy: "openai/qa-test-model",
|
||||
results: [{ model: "openai/qa-test-model", status: "ok", latencyMs: 42 }],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/combos/metrics", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ metrics: {} }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(/\/api\/settings$/, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ comboConfigMode: "guided" }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/settings/proxy", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ combos: {} }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/providers", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
connections: [{ id: "conn-openai", provider: "openai", testStatus: "active" }],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/provider-nodes", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ nodes: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/models/alias", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ aliases: {} }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/provider-models", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
models: {
|
||||
openai: [{ id: "qa-test-model", name: "QA Test Model" }],
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/pricing", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
openai: {
|
||||
"qa-test-model": {
|
||||
input: 0.01,
|
||||
output: 0.02,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/combos/builder/options", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
providers: [
|
||||
{
|
||||
providerId: "openai",
|
||||
displayName: "OpenAI",
|
||||
connectionCount: 1,
|
||||
models: [{ id: "qa-test-model", name: "QA Test Model" }],
|
||||
connections: [
|
||||
{
|
||||
id: "conn-openai",
|
||||
label: "OpenAI Primary",
|
||||
status: "active",
|
||||
priority: 1,
|
||||
defaultModel: "qa-test-model",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
comboRefs: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/combos", async (route) => {
|
||||
const method = route.request().method();
|
||||
if (method === "GET") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ combos: state.combos }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST") {
|
||||
const payloadRaw = route.request().postDataJSON();
|
||||
const payload =
|
||||
payloadRaw && typeof payloadRaw === "object" ? (payloadRaw as ComboCreatePayload) : {};
|
||||
const comboId = `combo-${state.nextId++}`;
|
||||
const createdCombo = {
|
||||
id: comboId,
|
||||
name: payload.name || comboId,
|
||||
strategy: payload.strategy || "priority",
|
||||
models: payload.models || [],
|
||||
config: payload.config || {},
|
||||
isActive: true,
|
||||
};
|
||||
state.combos.push(createdCombo);
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ combo: createdCombo }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 405,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "Method not allowed in test stub" }),
|
||||
});
|
||||
});
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/combos", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await expect(
|
||||
page.getByRole("button", { name: /create combo|criar combo/i }).first()
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: /create combo|criar combo/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const comboDialog = page.getByRole("dialog").first();
|
||||
await expect(comboDialog).toBeVisible();
|
||||
const comboNextButton = comboDialog.locator('[data-testid="combo-builder-next"]');
|
||||
await expect(comboNextButton).toBeDisabled();
|
||||
|
||||
await comboDialog.locator('[data-testid="combo-template-high-availability"]').click();
|
||||
await expect(comboNextButton).toBeEnabled();
|
||||
await comboNextButton.click();
|
||||
|
||||
await expect(comboDialog.locator('[data-testid="combo-builder-stage-steps"]')).toBeVisible();
|
||||
await expect(comboNextButton).toBeDisabled();
|
||||
await comboDialog.locator('[data-testid="combo-builder-provider"]').selectOption("openai");
|
||||
await comboDialog.locator('[data-testid="combo-builder-model"]').selectOption("qa-test-model");
|
||||
await comboDialog.locator('[data-testid="combo-builder-account"]').selectOption("conn-openai");
|
||||
await comboDialog.locator('[data-testid="combo-builder-add-step"]').click();
|
||||
await expect(comboNextButton).toBeEnabled();
|
||||
await comboNextButton.click();
|
||||
|
||||
const applyRecommendationsButton = comboDialog
|
||||
.getByRole("button", { name: /apply recommendations|aplicar recomendações/i })
|
||||
.first();
|
||||
|
||||
await expect(applyRecommendationsButton).toBeVisible();
|
||||
await comboDialog.locator('[data-testid="strategy-option-weighted"]').click();
|
||||
await expect(comboDialog.locator('[data-testid="strategy-change-nudge"]')).toBeVisible();
|
||||
await comboDialog.locator('[data-testid="strategy-option-priority"]').click();
|
||||
await expect(comboDialog.locator('[data-testid="strategy-change-nudge"]')).toBeVisible();
|
||||
await applyRecommendationsButton.click();
|
||||
|
||||
await comboNextButton.click();
|
||||
|
||||
const comboCreateButton = comboDialog
|
||||
.getByRole("button", { name: /create combo|criar combo/i })
|
||||
.last();
|
||||
const readinessPanel = comboDialog.locator('[data-testid="combo-readiness-panel"]');
|
||||
const saveBlockers = comboDialog.locator('[data-testid="combo-save-blockers"]');
|
||||
|
||||
await expect(readinessPanel).toBeVisible();
|
||||
await expect(saveBlockers).toHaveCount(0);
|
||||
await expect(comboCreateButton).toBeEnabled();
|
||||
|
||||
await comboCreateButton.click();
|
||||
await expect(comboDialog).toBeHidden();
|
||||
|
||||
const quickTestButton = page.getByRole("button", { name: /test now|testar agora/i });
|
||||
await expect(quickTestButton).toBeVisible();
|
||||
await quickTestButton.click();
|
||||
|
||||
await expect
|
||||
.poll(() => state.comboTestRequests, {
|
||||
message: "Expected the quick test CTA to hit /api/combos/test once",
|
||||
})
|
||||
.toBe(1);
|
||||
|
||||
const testResultsModal = page.getByRole("dialog").last();
|
||||
await expect(testResultsModal).toContainText(/qa-test-model/i);
|
||||
});
|
||||
|
||||
test("expert mode shows a single-page combo form with manual model entry", async ({ page }) => {
|
||||
const state: {
|
||||
combos: ComboStub[];
|
||||
nextId: number;
|
||||
lastPayload: ComboCreatePayload | null;
|
||||
} = {
|
||||
combos: [],
|
||||
nextId: 1,
|
||||
lastPayload: null,
|
||||
};
|
||||
|
||||
await page.route("**/api/combos/metrics", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ metrics: {} }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(/\/api\/settings$/, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ comboConfigMode: "expert" }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/settings/proxy", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ combos: {} }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/providers", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
connections: [
|
||||
{ id: "conn-codex", provider: "codex", testStatus: "active" },
|
||||
{ id: "conn-openrouter", provider: "openrouter", testStatus: "active" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/provider-nodes", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ nodes: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/models/alias", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ aliases: {} }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/pricing", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/combos/builder/options", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
providers: [
|
||||
{
|
||||
providerId: "codex",
|
||||
alias: "cx",
|
||||
displayName: "Codex",
|
||||
connectionCount: 1,
|
||||
models: [],
|
||||
connections: [
|
||||
{
|
||||
id: "conn-codex",
|
||||
label: "Codex Primary",
|
||||
status: "active",
|
||||
priority: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
providerId: "openrouter",
|
||||
alias: "openrouter",
|
||||
displayName: "OpenRouter",
|
||||
connectionCount: 1,
|
||||
models: [],
|
||||
connections: [
|
||||
{
|
||||
id: "conn-openrouter",
|
||||
label: "OpenRouter Primary",
|
||||
status: "active",
|
||||
priority: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
comboRefs: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/combos", async (route) => {
|
||||
const method = route.request().method();
|
||||
if (method === "GET") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ combos: state.combos }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST") {
|
||||
const payloadRaw = route.request().postDataJSON();
|
||||
const payload =
|
||||
payloadRaw && typeof payloadRaw === "object" ? (payloadRaw as ComboCreatePayload) : {};
|
||||
state.lastPayload = payload;
|
||||
const comboId = `combo-${state.nextId++}`;
|
||||
const createdCombo = {
|
||||
id: comboId,
|
||||
name: payload.name || comboId,
|
||||
strategy: payload.strategy || "priority",
|
||||
models: payload.models || [],
|
||||
config: payload.config || {},
|
||||
isActive: true,
|
||||
};
|
||||
state.combos.push(createdCombo);
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ combo: createdCombo }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({ status: 405, body: "Method not allowed in test stub" });
|
||||
});
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/combos", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await page
|
||||
.getByRole("button", { name: /create combo|criar combo/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const comboDialog = page.getByRole("dialog").first();
|
||||
await expect(comboDialog).toBeVisible();
|
||||
await expect(comboDialog.locator('[data-testid="combo-builder-next"]')).toHaveCount(0);
|
||||
await expect(comboDialog.locator('[data-testid="combo-builder-stage-steps"]')).toHaveCount(0);
|
||||
await expect(comboDialog.locator('[data-testid="combo-readiness-panel"]')).toBeVisible();
|
||||
await expect(comboDialog.locator('[data-testid="combo-browse-catalog"]')).toBeVisible();
|
||||
await comboDialog.locator('[data-testid="combo-browse-catalog"]').click();
|
||||
const modelCatalogDialog = page.getByRole("dialog", {
|
||||
name: /add model to combo|adicionar modelo ao combo/i,
|
||||
});
|
||||
await expect(modelCatalogDialog).toBeVisible();
|
||||
await modelCatalogDialog.getByRole("button", { name: /close/i }).click();
|
||||
await expect(comboDialog.getByText(/recommended setup|how to use this strategy/i)).toHaveCount(
|
||||
0
|
||||
);
|
||||
|
||||
await comboDialog.locator('[data-testid="combo-name-input"]').fill("expert-stack");
|
||||
await comboDialog.locator('[data-testid="combo-manual-model-input"]').fill("cx/gpt-5.5");
|
||||
await comboDialog.locator('[data-testid="combo-manual-model-add"]').click();
|
||||
await comboDialog
|
||||
.locator('[data-testid="combo-manual-model-input"]')
|
||||
.fill("openrouter/openai/gpt-5.5");
|
||||
await comboDialog.locator('[data-testid="combo-manual-model-add"]').click();
|
||||
await expect(comboDialog.locator('[data-testid="combo-readiness-panel"]')).toHaveCount(0);
|
||||
|
||||
// New advanced settings: failoverBeforeRetry, maxSetRetries, setRetryDelayMs
|
||||
await comboDialog.locator("#failoverBeforeRetry").check();
|
||||
// maxSetRetries: placeholder "0" is unique (maxRetries uses "1")
|
||||
await comboDialog.locator('input[placeholder="0"]').fill("2");
|
||||
// setRetryDelayMs: placeholder "2000" — last occurrence is the new field
|
||||
await comboDialog.locator('input[placeholder="2000"]').last().fill("1500");
|
||||
|
||||
await comboDialog
|
||||
.getByRole("button", { name: /create combo|criar combo/i })
|
||||
.last()
|
||||
.click();
|
||||
await expect(comboDialog).toBeHidden();
|
||||
|
||||
expect(state.lastPayload?.models).toEqual([
|
||||
{
|
||||
kind: "model",
|
||||
providerId: "codex",
|
||||
model: "codex/gpt-5.5",
|
||||
weight: 0,
|
||||
},
|
||||
{
|
||||
kind: "model",
|
||||
providerId: "openrouter",
|
||||
model: "openrouter/openai/gpt-5.5",
|
||||
weight: 0,
|
||||
},
|
||||
]);
|
||||
expect(state.lastPayload?.config?.failoverBeforeRetry).toBe(true);
|
||||
expect(state.lastPayload?.config?.maxSetRetries).toBe(2);
|
||||
expect(state.lastPayload?.config?.setRetryDelayMs).toBe(1500);
|
||||
});
|
||||
|
||||
test("allows dragging combo cards to persist manual order", async ({ page }) => {
|
||||
const state: {
|
||||
combos: ComboStub[];
|
||||
reorderRequests: number;
|
||||
} = {
|
||||
combos: [
|
||||
{
|
||||
id: "combo-1",
|
||||
name: "alpha-combo",
|
||||
strategy: "priority",
|
||||
models: ["openai/alpha"],
|
||||
config: {},
|
||||
isActive: true,
|
||||
sortOrder: 1,
|
||||
},
|
||||
{
|
||||
id: "combo-2",
|
||||
name: "bravo-combo",
|
||||
strategy: "priority",
|
||||
models: ["openai/bravo"],
|
||||
config: {},
|
||||
isActive: true,
|
||||
sortOrder: 2,
|
||||
},
|
||||
{
|
||||
id: "combo-3",
|
||||
name: "charlie-combo",
|
||||
strategy: "priority",
|
||||
models: ["openai/charlie"],
|
||||
config: {},
|
||||
isActive: true,
|
||||
sortOrder: 3,
|
||||
},
|
||||
],
|
||||
reorderRequests: 0,
|
||||
};
|
||||
|
||||
await page.route("**/api/combos/metrics", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ metrics: {} }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(/\/api\/settings$/, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ comboConfigMode: "guided" }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/settings/proxy", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ combos: {} }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/providers", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ connections: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/provider-nodes", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ nodes: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/combos/reorder", async (route) => {
|
||||
state.reorderRequests += 1;
|
||||
const payload = route.request().postDataJSON() as { comboIds?: string[] };
|
||||
const nextIds = Array.isArray(payload?.comboIds) ? payload.comboIds : [];
|
||||
const comboById = new Map(state.combos.map((combo) => [combo.id, combo]));
|
||||
state.combos = nextIds
|
||||
.map((id, index) => {
|
||||
const combo = comboById.get(id);
|
||||
return combo ? { ...combo, sortOrder: index + 1 } : null;
|
||||
})
|
||||
.filter((combo): combo is ComboStub => combo !== null);
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ combos: state.combos }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/combos", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ combos: state.combos }),
|
||||
});
|
||||
});
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/combos", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await expect(page.getByTestId("combo-card-combo-1")).toBeVisible();
|
||||
|
||||
const comboCards = page.locator('[data-testid^="combo-card-"]');
|
||||
await expect
|
||||
.poll(async () =>
|
||||
comboCards.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")))
|
||||
)
|
||||
.toEqual(["combo-card-combo-1", "combo-card-combo-2", "combo-card-combo-3"]);
|
||||
|
||||
await dispatchHtml5DragAndDrop(
|
||||
page,
|
||||
page.getByTestId("combo-drag-handle-combo-3"),
|
||||
page.getByTestId("combo-card-combo-1")
|
||||
);
|
||||
|
||||
await expect.poll(() => state.reorderRequests).toBe(1);
|
||||
await expect
|
||||
.poll(async () =>
|
||||
comboCards.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")))
|
||||
)
|
||||
.toEqual(["combo-card-combo-3", "combo-card-combo-1", "combo-card-combo-2"]);
|
||||
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByTestId("combo-card-combo-3")).toBeVisible();
|
||||
|
||||
await expect
|
||||
.poll(async () =>
|
||||
comboCards.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")))
|
||||
)
|
||||
.toEqual(["combo-card-combo-3", "combo-card-combo-1", "combo-card-combo-2"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
|
||||
|
||||
/**
|
||||
* T03 — Compression Studio (Tela A) smoke e2e (gaps v3.8.42).
|
||||
*
|
||||
* The studio's reducers/renderers (compressionFlowModel, WaterfallInspector,
|
||||
* CompressionCockpit, PlayView/CompareView) are unit/vitest-covered. What unit tests
|
||||
* CANNOT catch is a client-side render / hydration crash on the real page — the same
|
||||
* "no `useTranslations` trap" risk the compression-UI plan flagged. The existing
|
||||
* combo-live-studio spec guards `/dashboard/combos/live`; this is its missing
|
||||
* counterpart for the dedicated compression studio (Tela A), which that spec does not
|
||||
* touch.
|
||||
*
|
||||
* It loads /dashboard/compression/studio and asserts the studio mounts (Play tab + its
|
||||
* lane), then flips to the Compare tab and asserts the switch took effect. The
|
||||
* visibility assertions ARE the hydration-trap guard: a render-time crash would mean
|
||||
* none of these testids ever appear.
|
||||
*
|
||||
* Out of scope (kept unit-covered, to stay non-flaky): driving a live WS compression
|
||||
* cascade (needs `compression.step` events an e2e cannot inject) and asserting console
|
||||
* output (dev-mode on-demand compilation emits transient fast-refresh noise).
|
||||
*/
|
||||
test.describe("Compression Studio (Tela A)", () => {
|
||||
test("loads /dashboard/compression/studio and renders the Play lane without crashing", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/compression/studio");
|
||||
|
||||
const playTab = page.locator('[data-testid="tab-play"]');
|
||||
await expect(playTab).toBeVisible({ timeout: 30_000 });
|
||||
// Play view is the default tab → its playground input proves the studio body mounted.
|
||||
// NOTE: the per-lane `play-lane` buttons only render AFTER a preview-compression run
|
||||
// populates `batch.lanes` (usePreviewCompression keeps `batch` null until `run()` is
|
||||
// called — there is no mount auto-run). This smoke test intentionally does not drive a
|
||||
// compression cascade (see the "Out of scope" note above), so asserting `play-lane`
|
||||
// here can never become visible. Anchor on the always-present input panel instead.
|
||||
await expect(page.locator('[data-testid="play-input"]')).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("switches from Play to Compare", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/compression/studio");
|
||||
|
||||
const compareTab = page.locator('[data-testid="tab-compare"]');
|
||||
await expect(compareTab).toBeVisible({ timeout: 30_000 });
|
||||
await compareTab.click();
|
||||
await expect(compareTab).toHaveAttribute("aria-pressed", "true");
|
||||
// Compare view mounted → its load control is present.
|
||||
await expect(page.locator('[data-testid="compare-load"]').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* E2E Test Suite — OmniRoute Ecosystem
|
||||
*
|
||||
* 6 scenarios covering MCP, A2A, Auto-Combo, Extension, Stress, and Security.
|
||||
* Run with: npm run test:ecosystem
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
|
||||
const BASE_URL = process.env.OMNIROUTE_BASE_URL || "http://localhost:20128";
|
||||
const API_KEY = process.env.OMNIROUTE_API_KEY || "";
|
||||
const REQUEST_TIMEOUT_MS = Number(process.env.ECOSYSTEM_REQUEST_TIMEOUT_MS || 30000);
|
||||
const TEST_TIMEOUT_MS = Number(process.env.ECOSYSTEM_TEST_TIMEOUT_MS || 30000);
|
||||
const STRESS_TIMEOUT_MS = Number(process.env.ECOSYSTEM_STRESS_TIMEOUT_MS || 45000);
|
||||
|
||||
function itCase(name: string, fn: () => Promise<void> | void) {
|
||||
return it(name, fn, TEST_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
function itStress(name: string, fn: () => Promise<void> | void) {
|
||||
return it(name, fn, STRESS_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
async function apiFetch(path: string, options?: RequestInit) {
|
||||
return fetch(`${BASE_URL}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}),
|
||||
...(options?.headers || {}),
|
||||
},
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Scenario 1: MCP Server Complete ─────────────────────────────
|
||||
describe("E2E: MCP Server (16 tools)", () => {
|
||||
itCase("should respond to health check", async () => {
|
||||
const res = await apiFetch("/api/monitoring/health");
|
||||
expect(res.ok).toBe(true);
|
||||
const data = (await res.json()) as any;
|
||||
expect(data).toHaveProperty("status");
|
||||
});
|
||||
|
||||
itCase("should list combos", async () => {
|
||||
const res = await apiFetch("/api/combos");
|
||||
expect(res.ok).toBe(true);
|
||||
const data = (await res.json()) as any;
|
||||
expect(Array.isArray(data?.combos)).toBe(true);
|
||||
});
|
||||
|
||||
itCase("should return quota data", async () => {
|
||||
const res = await apiFetch("/api/usage/quota");
|
||||
expect(res.ok).toBe(true);
|
||||
const data = (await res.json()) as any;
|
||||
expect(Array.isArray(data?.providers)).toBe(true);
|
||||
expect(data).toHaveProperty("meta");
|
||||
});
|
||||
|
||||
itCase("should return usage analytics", async () => {
|
||||
const res = await apiFetch("/api/usage/analytics?period=session");
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
itCase("should return model catalog", async () => {
|
||||
const res = await apiFetch("/api/models");
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Scenario 1B: Quota Contract ─────────────────────────────
|
||||
describe("E2E: Quota Contract (/api/usage/quota)", () => {
|
||||
itCase("should return normalized quota response shape", async () => {
|
||||
const res = await apiFetch("/api/usage/quota");
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
const data = (await res.json()) as any;
|
||||
expect(Array.isArray(data.providers)).toBe(true);
|
||||
expect(data).toHaveProperty("meta");
|
||||
expect(typeof data.meta.generatedAt).toBe("string");
|
||||
expect(typeof data.meta.totalProviders).toBe("number");
|
||||
|
||||
if (data.providers.length > 0) {
|
||||
const p = data.providers[0];
|
||||
expect(typeof p.name).toBe("string");
|
||||
expect(typeof p.provider).toBe("string");
|
||||
expect(typeof p.connectionId).toBe("string");
|
||||
expect(typeof p.quotaUsed).toBe("number");
|
||||
expect(typeof p.percentRemaining).toBe("number");
|
||||
expect(p.percentRemaining).toBeGreaterThanOrEqual(0);
|
||||
expect(p.percentRemaining).toBeLessThanOrEqual(100);
|
||||
expect(["valid", "expiring", "expired", "refreshing"]).toContain(p.tokenStatus);
|
||||
}
|
||||
});
|
||||
|
||||
itCase("should filter quota by provider", async () => {
|
||||
const allRes = await apiFetch("/api/usage/quota");
|
||||
expect(allRes.ok).toBe(true);
|
||||
const allData = (await allRes.json()) as any;
|
||||
if (!Array.isArray(allData.providers) || allData.providers.length === 0) return;
|
||||
|
||||
const provider = allData.providers[0].provider;
|
||||
const filteredRes = await apiFetch(`/api/usage/quota?provider=${encodeURIComponent(provider)}`);
|
||||
expect(filteredRes.ok).toBe(true);
|
||||
const filteredData = (await filteredRes.json()) as any;
|
||||
expect(filteredData.meta.filters.provider).toBe(provider);
|
||||
expect(Array.isArray(filteredData.providers)).toBe(true);
|
||||
expect(filteredData.providers.every((p: any) => p.provider === provider)).toBe(true);
|
||||
});
|
||||
|
||||
itCase("should filter quota by connectionId", async () => {
|
||||
const allRes = await apiFetch("/api/usage/quota");
|
||||
expect(allRes.ok).toBe(true);
|
||||
const allData = (await allRes.json()) as any;
|
||||
if (!Array.isArray(allData.providers) || allData.providers.length === 0) return;
|
||||
|
||||
const connectionId = allData.providers[0].connectionId;
|
||||
const filteredRes = await apiFetch(
|
||||
`/api/usage/quota?connectionId=${encodeURIComponent(connectionId)}`
|
||||
);
|
||||
expect(filteredRes.ok).toBe(true);
|
||||
const filteredData = (await filteredRes.json()) as any;
|
||||
expect(filteredData.meta.filters.connectionId).toBe(connectionId);
|
||||
expect(Array.isArray(filteredData.providers)).toBe(true);
|
||||
expect(filteredData.providers.every((p: any) => p.connectionId === connectionId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Scenario 2: A2A Server Complete ─────────────────────────────
|
||||
describe("E2E: A2A Server (lifecycle)", () => {
|
||||
beforeAll(async () => {
|
||||
await apiFetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ a2aEnabled: true }),
|
||||
});
|
||||
});
|
||||
itCase("should serve Agent Card", async () => {
|
||||
const res = await apiFetch("/.well-known/agent.json");
|
||||
expect(res.ok).toBe(true);
|
||||
const card = (await res.json()) as any;
|
||||
expect(card).toHaveProperty("name");
|
||||
expect(card).toHaveProperty("skills");
|
||||
expect(card).toHaveProperty("version");
|
||||
expect(card.capabilities).toHaveProperty("streaming");
|
||||
});
|
||||
|
||||
itCase("should accept message/send via JSON-RPC", async () => {
|
||||
const res = await apiFetch("/a2a", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: "e2e-1",
|
||||
method: "message/send",
|
||||
params: {
|
||||
skill: "quota-management",
|
||||
messages: [{ role: "user", content: "show quota ranking" }],
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
const data = (await res.json()) as any;
|
||||
expect(data).toHaveProperty("result");
|
||||
expect(data.result.task).toHaveProperty("id");
|
||||
expect(data.result.task).toHaveProperty("state");
|
||||
});
|
||||
|
||||
itCase("should reject invalid JSON-RPC method", async () => {
|
||||
const res = await apiFetch("/a2a", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: "e2e-2",
|
||||
method: "invalid/method",
|
||||
params: {},
|
||||
}),
|
||||
});
|
||||
const data = (await res.json()) as any;
|
||||
expect(data).toHaveProperty("error");
|
||||
expect(data.error.code).toBe(-32601);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Scenario 3: Auto-Combo ─────────────────────────────────────
|
||||
describe("E2E: Auto-Combo (routing + self-healing)", () => {
|
||||
itCase("should create auto-combo", async () => {
|
||||
const res = await apiFetch("/api/combos", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: `e2e-auto-test-${Date.now()}`,
|
||||
strategy: "auto",
|
||||
models: [{ model: "gpt-4" }],
|
||||
config: {
|
||||
candidatePool: ["anthropic", "google"],
|
||||
modePack: "ship-fast",
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!res.ok) console.error("POST /api/combos failed:", await res.text());
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
itCase("should list auto-combos", async () => {
|
||||
const res = await apiFetch("/api/combos");
|
||||
if (!res.ok) console.error("GET /api/combos failed:", await res.text());
|
||||
expect(res.ok).toBe(true);
|
||||
const data = (await res.json()) as any;
|
||||
expect(Array.isArray(data?.combos)).toBe(true);
|
||||
const autoCombos = data.combos.filter((c: any) => c.strategy === "auto");
|
||||
expect(autoCombos.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Scenario 4: OpenClaw Integration ────────────────────────────
|
||||
describe("E2E: OpenClaw Integration", () => {
|
||||
itCase("should return dynamic provider.order", async () => {
|
||||
const res = await apiFetch("/api/cli-tools/openclaw/auto-order");
|
||||
expect(res.ok).toBe(true);
|
||||
const data = (await res.json()) as any;
|
||||
expect(data).toHaveProperty("provider");
|
||||
expect(data.provider).toHaveProperty("order");
|
||||
expect(Array.isArray(data.provider.order)).toBe(true);
|
||||
expect(data.provider).toHaveProperty("allow_fallbacks");
|
||||
expect(data).toHaveProperty("source");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Scenario 5: Stress Test ─────────────────────────────────────
|
||||
describe("E2E: Stress (100 parallel requests)", () => {
|
||||
itStress("should handle 100 health checks in <10s", async () => {
|
||||
const start = Date.now();
|
||||
const promises = Array.from({ length: 100 }, (_, i) =>
|
||||
apiFetch("/api/monitoring/health").then((r) => ({
|
||||
ok: r.ok,
|
||||
index: i,
|
||||
}))
|
||||
);
|
||||
const results = await Promise.allSettled(promises);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
const successful = results.filter((r) => r.status === "fulfilled" && r.value.ok).length;
|
||||
|
||||
expect(successful).toBeGreaterThanOrEqual(90); // allow 10% failure
|
||||
expect(elapsed).toBeLessThan(10_000);
|
||||
});
|
||||
|
||||
itStress("should handle 50 parallel quota checks", async () => {
|
||||
const promises = Array.from({ length: 50 }, () =>
|
||||
apiFetch("/api/usage/quota").then((r) => r.ok)
|
||||
);
|
||||
const results = await Promise.allSettled(promises);
|
||||
const successful = results.filter((r) => r.status === "fulfilled" && r.value).length;
|
||||
expect(successful).toBeGreaterThanOrEqual(40);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Scenario 6: Security ────────────────────────────────────────
|
||||
describe("E2E: Security", () => {
|
||||
itCase("should handle missing A2A auth according to server configuration", async () => {
|
||||
const res = await fetch(`${BASE_URL}/a2a`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
// Intentionally no Authorization header
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: "sec-1",
|
||||
method: "message/send",
|
||||
params: { skill: "quota-management", messages: [] },
|
||||
}),
|
||||
});
|
||||
if (API_KEY) {
|
||||
expect(res.status).toBeGreaterThanOrEqual(401);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status !== 200) {
|
||||
console.log("SEC-1 FAILED STATUS:", res.status, "BODY:", await res.text());
|
||||
}
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
itCase("should handle invalid API keys according to server configuration", async () => {
|
||||
const res = await fetch(`${BASE_URL}/a2a`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer invalid-key-12345",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: "sec-2",
|
||||
method: "message/send",
|
||||
params: { skill: "quota-management", messages: [] },
|
||||
}),
|
||||
});
|
||||
if (API_KEY) {
|
||||
expect(res.status).toBeGreaterThanOrEqual(401);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status !== 200) {
|
||||
console.log("SEC-2 FAILED STATUS:", res.status, "BODY:", await res.text());
|
||||
}
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
itCase("should not expose internal errors in API responses", async () => {
|
||||
const res = await apiFetch("/api/monitoring/health", {
|
||||
method: "PATCH",
|
||||
});
|
||||
// Should return method error without leaking server internals
|
||||
expect(res.status).toBe(405);
|
||||
const body = await res.text();
|
||||
expect(body.includes("Error:")).toBe(false);
|
||||
});
|
||||
|
||||
itCase("should validate JSON-RPC request format", async () => {
|
||||
const res = await apiFetch("/a2a", {
|
||||
method: "POST",
|
||||
body: "not-json",
|
||||
});
|
||||
const data = (await res.json()) as any;
|
||||
if (data.error) {
|
||||
expect(data.error.code).toBe(-32700); // Parse error
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const errorPages = [
|
||||
{
|
||||
path: "/400",
|
||||
heading: "Bad Request",
|
||||
primaryHref: "/docs",
|
||||
secondaryHref: "/dashboard/translator",
|
||||
},
|
||||
{
|
||||
path: "/401",
|
||||
heading: "Unauthorized",
|
||||
primaryHref: "/login",
|
||||
secondaryHref: "/dashboard/api-manager",
|
||||
},
|
||||
{
|
||||
path: "/403",
|
||||
heading: "Forbidden",
|
||||
primaryHref: "/forbidden",
|
||||
secondaryHref: "/dashboard/settings?tab=security",
|
||||
},
|
||||
{
|
||||
path: "/408",
|
||||
heading: "Request Timeout",
|
||||
primaryHref: "/dashboard/endpoint",
|
||||
secondaryHref: "/status",
|
||||
},
|
||||
{
|
||||
path: "/429",
|
||||
heading: "Too Many Requests",
|
||||
primaryHref: "/dashboard/settings?tab=resilience",
|
||||
secondaryHref: "/dashboard/combos",
|
||||
},
|
||||
{
|
||||
path: "/500",
|
||||
heading: "Internal Server Error",
|
||||
primaryHref: "/dashboard/health",
|
||||
secondaryHref: "/dashboard/logs",
|
||||
},
|
||||
{
|
||||
path: "/502",
|
||||
heading: "Bad Gateway",
|
||||
primaryHref: "/dashboard/providers",
|
||||
secondaryHref: "/dashboard/translator",
|
||||
},
|
||||
{
|
||||
path: "/503",
|
||||
heading: "Service Unavailable",
|
||||
primaryHref: "/maintenance",
|
||||
secondaryHref: "/status",
|
||||
},
|
||||
];
|
||||
|
||||
test.describe("Error and Resilience Pages", () => {
|
||||
for (const pageSpec of errorPages) {
|
||||
test(`${pageSpec.path} renders actionable recovery actions`, async ({ page }) => {
|
||||
const response = await page.goto(pageSpec.path);
|
||||
expect(response).toBeTruthy();
|
||||
const expectedHttpStatus = Number.parseInt(pageSpec.path.slice(1), 10);
|
||||
expect([200, expectedHttpStatus]).toContain(response?.status());
|
||||
|
||||
await expect(page.getByRole("heading", { name: pageSpec.heading })).toBeVisible();
|
||||
await expect(page.locator(`a[href="${pageSpec.primaryHref}"]`).first()).toBeVisible();
|
||||
await expect(page.locator(`a[href="${pageSpec.secondaryHref}"]`).first()).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test("missing route renders not-found recovery actions", async ({ page }) => {
|
||||
await page.goto("/route-that-does-not-exist");
|
||||
|
||||
await expect(page.getByRole("heading", { name: /Page not found/i })).toBeVisible();
|
||||
await expect(page.locator('a[href="/dashboard"]')).toBeVisible();
|
||||
await expect(page.locator('a[href="/status"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("/offline explains connectivity and offers recovery actions", async ({ page }) => {
|
||||
const response = await page.goto("/offline");
|
||||
expect(response?.ok()).toBeTruthy();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Connectivity Issue" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Retry Connection" })).toBeVisible();
|
||||
await expect(page.locator('a[href="/status"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("/maintenance provides maintenance guidance and status action", async ({ page }) => {
|
||||
const response = await page.goto("/maintenance");
|
||||
expect(response?.ok()).toBeTruthy();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Scheduled Maintenance" })).toBeVisible();
|
||||
await expect(page.locator('a[href="/status"]')).toBeVisible();
|
||||
await expect(page.locator('a[href="/dashboard/health"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("/status shows monitoring shell and refresh control", async ({ page }) => {
|
||||
const response = await page.goto("/status");
|
||||
expect(response?.ok()).toBeTruthy();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "System Status" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Refresh" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Group B — Activity Feed E2E spec.
|
||||
*
|
||||
* Validates that the new /dashboard/activity page (Group B, plan 16 F4) renders
|
||||
* correctly: header visible, timeline container present, and the page responds
|
||||
* with a 200 (not a redirect or error).
|
||||
*
|
||||
* Backend is mocked so this spec does not require a running upstream.
|
||||
*/
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
|
||||
|
||||
test.describe("Group B — Activity Feed", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Mock the audit-log endpoint used by the Activity feed
|
||||
await page.route("**/api/compliance/audit-log**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const level = url.searchParams.get("level");
|
||||
if (level === "high") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
id: "1",
|
||||
action: "provider.added",
|
||||
actor: "admin",
|
||||
target: "codex",
|
||||
severity: "info",
|
||||
timestamp: new Date().toISOString(),
|
||||
metadata: {},
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ entries: [], total: 0 }),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("activity page exists and returns 200", async ({ page }) => {
|
||||
const response = await page.goto("http://localhost:20128/dashboard/activity", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
// After login redirect the page should settle on activity or login
|
||||
expect(response?.status()).not.toBe(404);
|
||||
expect(response?.status()).not.toBe(500);
|
||||
});
|
||||
|
||||
test("activity page renders header and timeline container", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/activity");
|
||||
|
||||
// The page header should be visible (h1 or title element)
|
||||
const heading = page
|
||||
.locator("h1, [data-testid='activity-title']")
|
||||
.first();
|
||||
await expect(heading).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// ActivityFeed renders <div role="status"> (empty state) or a
|
||||
// <div class="divide-y..."> with a nested <ul class="divide-y..."> (entries).
|
||||
// Match those feed-specific shapes (plus the legacy testids). Deliberately
|
||||
// NOT matching a generic container like `div.rounded-xl`, which exists on
|
||||
// many pages (incl. the /login card) and would let the test pass even when
|
||||
// the dashboard redirected to login without rendering the feed.
|
||||
const feedContainer = page.locator(
|
||||
"[data-testid='activity-feed'], [data-testid='activity-empty-state']," +
|
||||
" .activity-feed, [role='status'], [role='list'], ul.divide-y, div.divide-y"
|
||||
);
|
||||
await expect(feedContainer.first()).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
test("activity page does not show raw error stack traces", async ({ page }) => {
|
||||
// Simulate a backend error to ensure error sanitization (Hard Rule #12)
|
||||
await page.route("**/api/compliance/audit-log**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: { message: "Internal error" } }),
|
||||
});
|
||||
});
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/activity");
|
||||
|
||||
const pageContent = await page.content();
|
||||
// Stack traces should never appear in the UI (Hard Rule #12)
|
||||
expect(pageContent).not.toMatch(/\s+at\s+\//);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Group B — Quota Plans Config E2E spec.
|
||||
*
|
||||
* The originally planned standalone page /dashboard/costs/quota-share/plans does not
|
||||
* exist in the current codebase (Group B plan 22 F9 implemented plans via the
|
||||
* PoolWizard inside /dashboard/costs/quota-share, not a separate route).
|
||||
*
|
||||
* Tests are corrected to navigate to the existing /dashboard/costs/quota-share page
|
||||
* which contains the group <select> element (QuotaSharePageClient.tsx line ~362).
|
||||
* Backend is mocked so this spec does not require a running upstream.
|
||||
*/
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
|
||||
|
||||
test.describe("Group B — Quota Plans Config", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Mock the plans list endpoint
|
||||
await page.route("**/api/quota/plans**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const pathParts = url.pathname.split("/");
|
||||
const lastPart = pathParts[pathParts.length - 1];
|
||||
|
||||
if (lastPart === "plans") {
|
||||
// List all plans
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{
|
||||
connectionId: null,
|
||||
provider: "codex",
|
||||
dimensions: [
|
||||
{ unit: "percent", window: "5h", limit: 100 },
|
||||
{ unit: "percent", window: "weekly", limit: 100 },
|
||||
],
|
||||
source: "auto",
|
||||
},
|
||||
]),
|
||||
});
|
||||
} else {
|
||||
// Single plan by connectionId
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
connectionId: lastPart,
|
||||
provider: "codex",
|
||||
dimensions: [
|
||||
{ unit: "percent", window: "5h", limit: 100 },
|
||||
{ unit: "percent", window: "weekly", limit: 100 },
|
||||
],
|
||||
source: "auto",
|
||||
}),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Mock pools list — QuotaSharePageClient uses usePools() which fetches /api/quota/pools
|
||||
await page.route("**/api/quota/pools**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([]),
|
||||
});
|
||||
});
|
||||
|
||||
// Mock pool groups list
|
||||
await page.route("**/api/quota/groups**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([]),
|
||||
});
|
||||
});
|
||||
|
||||
// Mock provider connections list
|
||||
await page.route("**/api/providers/client**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([]),
|
||||
});
|
||||
});
|
||||
|
||||
// Mock API keys list
|
||||
await page.route("**/api/keys**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([]),
|
||||
});
|
||||
});
|
||||
|
||||
// Mock quota-store settings
|
||||
await page.route("**/api/settings/quota-store**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ driver: "sqlite", redisUrl: null }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("quota share page exists and returns 200", async ({ page }) => {
|
||||
// /dashboard/costs/quota-share/plans does not exist as a standalone route;
|
||||
// the plans wizard is embedded in /dashboard/costs/quota-share.
|
||||
const response = await page.goto(
|
||||
"http://localhost:20128/dashboard/costs/quota-share",
|
||||
{ waitUntil: "domcontentloaded" }
|
||||
);
|
||||
expect(response?.status()).not.toBe(404);
|
||||
expect(response?.status()).not.toBe(500);
|
||||
});
|
||||
|
||||
test("quota plans config page renders provider selector", async ({ page }) => {
|
||||
// The standalone /plans sub-page was never created; the group <select> element
|
||||
// that allows filtering pools lives directly in /dashboard/costs/quota-share
|
||||
// (QuotaSharePageClient.tsx). Navigate there instead.
|
||||
await gotoDashboardRoute(page, "/dashboard/costs/quota-share");
|
||||
|
||||
// Group selector (a <select> element) should be visible
|
||||
const providerSelector = page.locator(
|
||||
"select, [role='combobox'], [data-testid='provider-selector']"
|
||||
);
|
||||
await expect(providerSelector.first()).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
test("selecting codex provider shows dimension rows", async ({ page }) => {
|
||||
// Navigate to the real quota-share page (plans are embedded, not a standalone route)
|
||||
await gotoDashboardRoute(page, "/dashboard/costs/quota-share");
|
||||
|
||||
// The group selector is a <select> element in QuotaSharePageClient
|
||||
const selector = page.locator("select, [role='combobox']").first();
|
||||
await expect(selector).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Select codex if the option is available (it will only appear if the mock
|
||||
// returns a group named "codex" — the current mock returns an empty groups list,
|
||||
// so the selector will only have the "All groups" option).
|
||||
const codexOption = page.getByRole("option", { name: /codex/i });
|
||||
if (await codexOption.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await selector.selectOption({ label: /codex/i });
|
||||
}
|
||||
|
||||
// After selection, the page should not be in a broken state.
|
||||
// Note: page.content() includes the full HTML source, which contains Next.js
|
||||
// chunk filenames — those hashes can legitimately contain the string "500".
|
||||
// Checking for "500" in raw HTML is unreliable; instead check for the actual
|
||||
// error boundary text that OmniRoute renders on unrecoverable errors
|
||||
// (src/app/error.tsx heading: "Internal Server Error").
|
||||
const pageContent = await page.content();
|
||||
expect(pageContent).not.toContain("Internal Server Error");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Group B — Quota Share Pools E2E spec.
|
||||
*
|
||||
* Validates that the redesigned /dashboard/costs/quota-share page (Group B,
|
||||
* plan 22 F9) renders correctly: QuotaConceptCard visible, pool list or empty
|
||||
* state present.
|
||||
*
|
||||
* Backend is mocked so this spec does not require a running upstream.
|
||||
*/
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
|
||||
|
||||
test.describe("Group B — Quota Share Pools", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Mock pools list — empty state first
|
||||
await page.route("**/api/quota/pools", async (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([]),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
// Mock plans list
|
||||
await page.route("**/api/quota/plans**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([]),
|
||||
});
|
||||
});
|
||||
|
||||
// Mock settings/quota-store
|
||||
await page.route("**/api/settings/quota-store", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ driver: "sqlite", redisUrl: null }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("quota-share page exists and returns 200", async ({ page }) => {
|
||||
const response = await page.goto(
|
||||
"http://localhost:20128/dashboard/costs/quota-share",
|
||||
{ waitUntil: "domcontentloaded" }
|
||||
);
|
||||
expect(response?.status()).not.toBe(404);
|
||||
expect(response?.status()).not.toBe(500);
|
||||
});
|
||||
|
||||
test("quota-share page renders QuotaConceptCard or pool list", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/costs/quota-share");
|
||||
|
||||
// Either the concept card (empty state) or a pool list should be visible
|
||||
const conceptCard = page.locator(
|
||||
"[data-testid='quota-concept-card'], [class*='QuotaConceptCard'], h2, h3"
|
||||
);
|
||||
await expect(conceptCard.first()).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
test("quota-share page shows pool list when pools exist", async ({ page }) => {
|
||||
// Override with a pool in the response
|
||||
await page.route("**/api/quota/pools", async (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: "pool-1",
|
||||
connectionId: "conn-codex-1",
|
||||
name: "Codex Shared Pool",
|
||||
createdAt: new Date().toISOString(),
|
||||
allocations: [],
|
||||
},
|
||||
]),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/costs/quota-share");
|
||||
|
||||
// Pool name should appear somewhere on the page
|
||||
await expect(page.getByText("Codex Shared Pool")).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Group B — Redirect /dashboard/logs/activity E2E spec.
|
||||
*
|
||||
* Validates that the old path /dashboard/logs/activity permanently redirects
|
||||
* (HTTP 308) to /dashboard/activity as implemented in Group B plan 16 (F4).
|
||||
*
|
||||
* This is a pure HTTP-level test — does not require full page render.
|
||||
*/
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Group B — /logs/activity redirect", () => {
|
||||
test("GET /dashboard/logs/activity redirects to /dashboard/activity", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// Follow redirects and verify the final URL is /dashboard/activity
|
||||
const response = await page.goto(
|
||||
"http://localhost:20128/dashboard/logs/activity",
|
||||
{ waitUntil: "domcontentloaded" }
|
||||
);
|
||||
|
||||
const finalUrl = page.url();
|
||||
// After following redirects, should end up at /dashboard/activity
|
||||
// (may also end up at /login if auth is required — that's OK, path is correct)
|
||||
expect(finalUrl).toMatch(/\/(login|dashboard\/activity)/);
|
||||
expect(finalUrl).not.toContain("/logs/activity");
|
||||
});
|
||||
|
||||
test("direct request to /dashboard/logs/activity issues a permanent redirect", async ({
|
||||
request,
|
||||
}) => {
|
||||
// Make a non-follow-redirect request to verify the redirect status code.
|
||||
const response = await request.get(
|
||||
"http://localhost:20128/dashboard/logs/activity",
|
||||
{
|
||||
maxRedirects: 0,
|
||||
}
|
||||
);
|
||||
|
||||
// Next.js permanentRedirect() returns 308 (or 307 in development mode).
|
||||
// When auth is required the server may respond with a 302/307 to /login
|
||||
// before the page component's permanentRedirect() executes.
|
||||
// Accept any redirect (3xx) and verify:
|
||||
// (a) the route does NOT return 200 (rendered without redirect) or 404/500
|
||||
// (b) the Location header points to either /dashboard/activity or /login
|
||||
const status = response.status();
|
||||
expect(status).toBeGreaterThanOrEqual(300);
|
||||
expect(status).toBeLessThan(400);
|
||||
|
||||
const location = response.headers()["location"] ?? "";
|
||||
expect(location).toMatch(/\/(login|dashboard\/activity)/);
|
||||
// The route must NOT stay on /logs/activity
|
||||
expect(location).not.toContain("/logs/activity");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
type GotoDashboardRouteOptions = {
|
||||
timeoutMs?: number;
|
||||
waitUntil?: "commit" | "domcontentloaded" | "load" | "networkidle";
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 300_000;
|
||||
const APP_ROUTE_PATTERN = /\/(login|dashboard)(\/[^?#]*)?([?#].*)?$/;
|
||||
const E2E_PASSWORD =
|
||||
process.env.OMNIROUTE_E2E_PASSWORD || process.env.INITIAL_PASSWORD || "omniroute-e2e-password";
|
||||
|
||||
async function waitForAppRoute(page: Page, timeoutMs: number) {
|
||||
await page.waitForURL(APP_ROUTE_PATTERN, { timeout: timeoutMs });
|
||||
await page.locator("body").waitFor({ state: "visible", timeout: timeoutMs });
|
||||
}
|
||||
|
||||
async function finishOnboardingIfNeeded(page: Page, timeoutMs: number) {
|
||||
if (!page.url().includes("/dashboard/onboarding")) return;
|
||||
|
||||
const skipWizardButton = page.getByRole("button", {
|
||||
name: /skip wizard|skip/i,
|
||||
});
|
||||
await expect(skipWizardButton).toBeVisible({ timeout: timeoutMs });
|
||||
await skipWizardButton.click();
|
||||
await page.waitForURL(/\/dashboard(\/.*)?$/, { timeout: timeoutMs });
|
||||
await page.locator("body").waitFor({ state: "visible", timeout: timeoutMs });
|
||||
}
|
||||
|
||||
async function loginIfNeeded(page: Page, timeoutMs: number) {
|
||||
if (!page.url().includes("/login")) return;
|
||||
|
||||
const passwordInput = page.locator('input[type="password"]').first();
|
||||
await expect(passwordInput).toBeVisible({ timeout: timeoutMs });
|
||||
await passwordInput.fill(E2E_PASSWORD);
|
||||
|
||||
const submitButton = page.locator("form").getByRole("button").first();
|
||||
await expect(submitButton).toBeEnabled({ timeout: timeoutMs });
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard(\/.*)?$/, { timeout: timeoutMs }),
|
||||
submitButton.click(),
|
||||
]);
|
||||
await page.locator("body").waitFor({ state: "visible", timeout: timeoutMs });
|
||||
}
|
||||
|
||||
async function getDashboardAuthState(page: Page) {
|
||||
return await page.evaluate(async () => {
|
||||
const safeJson = async (response: Response) => {
|
||||
try {
|
||||
return (await response.json()) as any;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const [requireLoginResponse, settingsResponse] = await Promise.all([
|
||||
fetch("/api/settings/require-login", {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
}),
|
||||
fetch("/api/settings", {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
}),
|
||||
]);
|
||||
|
||||
const requireLoginPayload = await safeJson(requireLoginResponse);
|
||||
|
||||
return {
|
||||
requireLogin: requireLoginPayload?.requireLogin === true,
|
||||
settingsStatus: settingsResponse.status,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function isAtRequestedRoute(page: Page, requestedUrl: string) {
|
||||
const current = new URL(page.url());
|
||||
const requested = new URL(requestedUrl, current.origin);
|
||||
return current.pathname === requested.pathname && current.search === requested.search;
|
||||
}
|
||||
|
||||
export async function gotoDashboardRoute(
|
||||
page: Page,
|
||||
url: string,
|
||||
options: GotoDashboardRouteOptions = {}
|
||||
) {
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const waitUntil = options.waitUntil ?? "commit";
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
await page.goto(url, { waitUntil, timeout: timeoutMs });
|
||||
await waitForAppRoute(page, timeoutMs);
|
||||
await finishOnboardingIfNeeded(page, timeoutMs);
|
||||
|
||||
if (page.url().includes("/login")) {
|
||||
await loginIfNeeded(page, timeoutMs);
|
||||
}
|
||||
|
||||
if (page.url().includes("/dashboard/onboarding") || page.url().includes("/login")) {
|
||||
await page.goto(url, { waitUntil, timeout: timeoutMs });
|
||||
await waitForAppRoute(page, timeoutMs);
|
||||
await finishOnboardingIfNeeded(page, timeoutMs);
|
||||
await loginIfNeeded(page, timeoutMs);
|
||||
}
|
||||
|
||||
const authState = await getDashboardAuthState(page);
|
||||
if (authState.requireLogin && authState.settingsStatus === 401) {
|
||||
await page.goto("/login", { waitUntil, timeout: timeoutMs });
|
||||
await waitForAppRoute(page, timeoutMs);
|
||||
await loginIfNeeded(page, timeoutMs);
|
||||
await page.goto(url, { waitUntil, timeout: timeoutMs });
|
||||
await waitForAppRoute(page, timeoutMs);
|
||||
await finishOnboardingIfNeeded(page, timeoutMs);
|
||||
}
|
||||
|
||||
if (!isAtRequestedRoute(page, url)) {
|
||||
await page.goto(url, { waitUntil, timeout: timeoutMs });
|
||||
await waitForAppRoute(page, timeoutMs);
|
||||
await finishOnboardingIfNeeded(page, timeoutMs);
|
||||
}
|
||||
|
||||
await page.locator("body").waitFor({ state: "visible", timeout: timeoutMs });
|
||||
return;
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error ? lastError : new Error(`Failed to open protected route ${url}`);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
|
||||
export interface PlannedResponse {
|
||||
status: number;
|
||||
body: Record<string, unknown>;
|
||||
headers?: Record<string, string>;
|
||||
delayMs?: number;
|
||||
}
|
||||
|
||||
export interface TokenState {
|
||||
defaultResponse: PlannedResponse;
|
||||
queue: PlannedResponse[];
|
||||
hits: number;
|
||||
startedAt: number[];
|
||||
bodies: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
function getFreePort(): Promise<number> {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close();
|
||||
reject(new Error("Failed to allocate a free port"));
|
||||
return;
|
||||
}
|
||||
const { port } = address;
|
||||
server.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCompletion(
|
||||
text: string,
|
||||
overrides: Partial<PlannedResponse> & { model?: string } = {}
|
||||
) {
|
||||
return {
|
||||
status: overrides.status ?? 200,
|
||||
delayMs: overrides.delayMs,
|
||||
headers: overrides.headers,
|
||||
body: overrides.body ?? {
|
||||
id: `chatcmpl_${Math.random().toString(16).slice(2, 8)}`,
|
||||
object: "chat.completion",
|
||||
model: overrides.model ?? "test-model",
|
||||
choices: [{ index: 0, message: { role: "assistant", content: text }, finish_reason: "stop" }],
|
||||
usage: { prompt_tokens: 4, completion_tokens: 2, total_tokens: 6 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildError(status: number, message: string, headers: Record<string, string> = {}) {
|
||||
return {
|
||||
status,
|
||||
headers,
|
||||
body: { error: { message } },
|
||||
};
|
||||
}
|
||||
|
||||
export class MockUpstreamServer {
|
||||
private behaviors = new Map<string, TokenState>();
|
||||
private server: http.Server | null = null;
|
||||
private _baseUrl = "";
|
||||
|
||||
get baseUrl(): string {
|
||||
if (!this._baseUrl) throw new Error("Server not started yet");
|
||||
return this._baseUrl;
|
||||
}
|
||||
|
||||
get isRunning(): boolean {
|
||||
return this.server !== null;
|
||||
}
|
||||
|
||||
async start(): Promise<string> {
|
||||
const port = await getFreePort();
|
||||
this.server = http.createServer((req, res) => {
|
||||
void this.handleRequest(req, res);
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.server!.once("error", reject);
|
||||
this.server!.listen(port, "127.0.0.1", () => resolve());
|
||||
});
|
||||
this._baseUrl = `http://127.0.0.1:${port}/v1`;
|
||||
return this._baseUrl;
|
||||
}
|
||||
|
||||
configureToken(
|
||||
token: string,
|
||||
config: { defaultResponse: PlannedResponse; queue?: PlannedResponse[] }
|
||||
): void {
|
||||
this.behaviors.set(token, {
|
||||
defaultResponse: config.defaultResponse,
|
||||
queue: [...(config.queue || [])],
|
||||
hits: 0,
|
||||
startedAt: [],
|
||||
bodies: [],
|
||||
});
|
||||
}
|
||||
|
||||
getState(token: string): TokenState {
|
||||
const state = this.behaviors.get(token);
|
||||
if (!state) throw new Error(`Unknown token: ${token}`);
|
||||
return state;
|
||||
}
|
||||
|
||||
resetState(token: string, queue?: PlannedResponse[]): void {
|
||||
const state = this.behaviors.get(token);
|
||||
if (!state) throw new Error(`Unknown token: ${token}`);
|
||||
state.hits = 0;
|
||||
state.startedAt = [];
|
||||
state.bodies = [];
|
||||
state.queue = [...(queue || [])];
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.server) return;
|
||||
await new Promise<void>((resolve) => this.server?.close(() => resolve()));
|
||||
this.server = null;
|
||||
}
|
||||
|
||||
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
|
||||
const authHeader = String(req.headers.authorization || "");
|
||||
const token = authHeader.replace(/^Bearer\s+/i, "").trim();
|
||||
const rawBody = Buffer.concat(chunks).toString("utf8");
|
||||
const parsedBody = rawBody ? JSON.parse(rawBody) : {};
|
||||
|
||||
if (req.method === "GET" && req.url?.startsWith("/v1/models")) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ data: [{ id: "test-model", object: "model" }] }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method !== "POST" || !req.url?.startsWith("/v1/chat/completions")) {
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: { message: `Unhandled: ${req.method} ${req.url}` } }));
|
||||
return;
|
||||
}
|
||||
|
||||
const behavior = this.behaviors.get(token);
|
||||
if (!behavior) {
|
||||
res.writeHead(401, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: { message: `Unknown token: ${token || "missing"}` } }));
|
||||
return;
|
||||
}
|
||||
|
||||
behavior.hits += 1;
|
||||
behavior.startedAt.push(Date.now());
|
||||
behavior.bodies.push(parsedBody as Record<string, unknown>);
|
||||
|
||||
const planned = behavior.queue.shift() || behavior.defaultResponse;
|
||||
process.stderr.write(
|
||||
`[MOCK] ${token.slice(0, 8)} hit=${behavior.hits} status=${planned.status}\n`
|
||||
);
|
||||
if (planned.delayMs && planned.delayMs > 0) {
|
||||
await new Promise((r) => setTimeout(r, planned.delayMs));
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(planned.headers || {}),
|
||||
};
|
||||
res.writeHead(planned.status, headers);
|
||||
res.end(JSON.stringify(planned.body));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
|
||||
|
||||
const NAVIGATION_TIMEOUT_MS = 300_000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type MemoryEntry = {
|
||||
id: string;
|
||||
apiKeyId: string;
|
||||
sessionId: string | null;
|
||||
type: "factual" | "episodic" | "procedural" | "semantic";
|
||||
key: string;
|
||||
content: string;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
|
||||
type MemoryStats = {
|
||||
totalEntries: number;
|
||||
tokensUsed: number;
|
||||
hitRate: number;
|
||||
cacheStats: { hits: number; misses: number };
|
||||
};
|
||||
|
||||
type MemorySettings = {
|
||||
enabled: boolean;
|
||||
maxTokens: number;
|
||||
retentionDays: number;
|
||||
strategy: "recent" | "semantic" | "hybrid";
|
||||
skillsEnabled: boolean;
|
||||
embeddingSource: "remote" | "static" | "transformers" | "auto";
|
||||
embeddingProviderModel: string | null;
|
||||
transformersEnabled: boolean;
|
||||
staticEnabled: boolean;
|
||||
rerankEnabled: boolean;
|
||||
rerankProviderModel: string | null;
|
||||
vectorStore: "sqlite-vec" | "qdrant" | "auto";
|
||||
};
|
||||
|
||||
type EngineStatus = {
|
||||
keyword: { available: true; backend: "FTS5" };
|
||||
embedding: {
|
||||
source: "remote" | "static" | "transformers" | null;
|
||||
model: string | null;
|
||||
dimensions: number | null;
|
||||
available: boolean;
|
||||
reason: string;
|
||||
cacheStats: { hits: number; misses: number; size: number };
|
||||
};
|
||||
vectorStore: {
|
||||
backend: "sqlite-vec" | "qdrant" | "none";
|
||||
available: boolean;
|
||||
rowCount: number;
|
||||
needsReindex: number;
|
||||
reason: string;
|
||||
};
|
||||
qdrant: {
|
||||
enabled: boolean;
|
||||
healthy: boolean | null;
|
||||
latencyMs: number | null;
|
||||
error: string | null;
|
||||
};
|
||||
rerank: {
|
||||
enabled: boolean;
|
||||
provider: string | null;
|
||||
model: string | null;
|
||||
available: boolean;
|
||||
reason: string;
|
||||
};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function makeMemory(overrides: Partial<MemoryEntry> = {}): MemoryEntry {
|
||||
return {
|
||||
id: "mem-test-1",
|
||||
apiKeyId: "key-1",
|
||||
sessionId: null,
|
||||
type: "factual",
|
||||
key: "test.preference.language",
|
||||
content: "The user prefers English responses.",
|
||||
metadata: {},
|
||||
createdAt: new Date("2026-05-01T10:00:00.000Z").toISOString(),
|
||||
updatedAt: new Date("2026-05-01T10:00:00.000Z").toISOString(),
|
||||
expiresAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultSettings(): MemorySettings {
|
||||
return {
|
||||
enabled: true,
|
||||
maxTokens: 2000,
|
||||
retentionDays: 30,
|
||||
strategy: "hybrid",
|
||||
skillsEnabled: false,
|
||||
embeddingSource: "auto",
|
||||
embeddingProviderModel: null,
|
||||
transformersEnabled: false,
|
||||
staticEnabled: false,
|
||||
rerankEnabled: false,
|
||||
rerankProviderModel: null,
|
||||
vectorStore: "auto",
|
||||
};
|
||||
}
|
||||
|
||||
function defaultEngineStatus(): EngineStatus {
|
||||
return {
|
||||
keyword: { available: true, backend: "FTS5" },
|
||||
embedding: {
|
||||
source: null,
|
||||
model: null,
|
||||
dimensions: null,
|
||||
available: false,
|
||||
reason: "No embedding source configured",
|
||||
cacheStats: { hits: 0, misses: 0, size: 0 },
|
||||
},
|
||||
vectorStore: {
|
||||
backend: "none",
|
||||
available: false,
|
||||
rowCount: 0,
|
||||
needsReindex: 0,
|
||||
reason: "sqlite-vec unavailable in this environment",
|
||||
},
|
||||
qdrant: { enabled: false, healthy: null, latencyMs: null, error: null },
|
||||
rerank: {
|
||||
enabled: false,
|
||||
provider: null,
|
||||
model: null,
|
||||
available: false,
|
||||
reason: "Rerank disabled",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route interceptors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function setupMemoryRoutes(
|
||||
page: Page,
|
||||
state: {
|
||||
memories: MemoryEntry[];
|
||||
stats: MemoryStats;
|
||||
settings: MemorySettings;
|
||||
engineStatus: EngineStatus;
|
||||
createCalls: number;
|
||||
updateCalls: number;
|
||||
deleteCalls: number;
|
||||
settingsCalls: number;
|
||||
reindexCalls: number;
|
||||
previewCalls: number;
|
||||
},
|
||||
) {
|
||||
// GET/POST /api/memory
|
||||
await page.route(/\/api\/memory(\?.*)?$/, async (route) => {
|
||||
const method = route.request().method();
|
||||
|
||||
if (method === "GET") {
|
||||
await fulfillJson(route, {
|
||||
data: state.memories,
|
||||
total: state.memories.length,
|
||||
totalPages: 1,
|
||||
stats: {
|
||||
total: state.memories.length,
|
||||
tokensUsed: state.stats.tokensUsed,
|
||||
hitRate: state.stats.hitRate,
|
||||
cacheStats: state.stats.cacheStats,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST") {
|
||||
state.createCalls += 1;
|
||||
const body = route.request().postDataJSON() as Partial<MemoryEntry>;
|
||||
const newMemory = makeMemory({
|
||||
id: `mem-new-${state.createCalls}`,
|
||||
key: body.key ?? "new.key",
|
||||
content: body.content ?? "New memory content.",
|
||||
type: (body.type as MemoryEntry["type"]) ?? "factual",
|
||||
sessionId: body.sessionId ?? null,
|
||||
apiKeyId: body.apiKeyId ?? "key-1",
|
||||
metadata: body.metadata ?? {},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
state.memories = [...state.memories, newMemory];
|
||||
state.stats.totalEntries = state.memories.length;
|
||||
await fulfillJson(route, newMemory, 201);
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(route, { error: { message: "Method not allowed" } }, 405);
|
||||
});
|
||||
|
||||
// GET/PUT /api/memory/[id] (must be after /api/memory$ route)
|
||||
await page.route(/\/api\/memory\/[^/]+$/, async (route) => {
|
||||
const method = route.request().method();
|
||||
const memoryId = route.request().url().split("/").pop()?.split("?")[0] ?? "";
|
||||
|
||||
if (method === "GET") {
|
||||
const mem = state.memories.find((m) => m.id === memoryId);
|
||||
if (!mem) {
|
||||
await fulfillJson(route, { error: { message: "Not found" } }, 404);
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, mem);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "PUT") {
|
||||
state.updateCalls += 1;
|
||||
const body = route.request().postDataJSON() as Partial<MemoryEntry>;
|
||||
state.memories = state.memories.map((m) => {
|
||||
if (m.id !== memoryId) return m;
|
||||
return {
|
||||
...m,
|
||||
...body,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
const updated = state.memories.find((m) => m.id === memoryId);
|
||||
await fulfillJson(route, updated ?? { error: { message: "Not found" } }, updated ? 200 : 404);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "DELETE") {
|
||||
state.deleteCalls += 1;
|
||||
state.memories = state.memories.filter((m) => m.id !== memoryId);
|
||||
state.stats.totalEntries = state.memories.length;
|
||||
await fulfillJson(route, { success: true });
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(route, { error: { message: "Method not allowed" } }, 405);
|
||||
});
|
||||
|
||||
// GET /api/memory/engine-status
|
||||
await page.route(/\/api\/memory\/engine-status$/, async (route) => {
|
||||
await fulfillJson(route, state.engineStatus);
|
||||
});
|
||||
|
||||
// GET /api/memory/embedding-providers
|
||||
await page.route(/\/api\/memory\/embedding-providers$/, async (route) => {
|
||||
await fulfillJson(route, { providers: [] });
|
||||
});
|
||||
|
||||
// POST /api/memory/retrieve-preview
|
||||
await page.route(/\/api\/memory\/retrieve-preview$/, async (route) => {
|
||||
state.previewCalls += 1;
|
||||
await fulfillJson(route, {
|
||||
memories: state.memories.slice(0, 3).map((m) => ({
|
||||
id: m.id,
|
||||
type: m.type,
|
||||
key: m.key,
|
||||
content: m.content,
|
||||
score: 0.9,
|
||||
tokens: 24,
|
||||
tier: "fts5",
|
||||
vecScore: null,
|
||||
ftsScore: 0.9,
|
||||
})),
|
||||
resolution: {
|
||||
embeddingSource: null,
|
||||
embeddingModel: null,
|
||||
vectorStore: "none",
|
||||
strategyUsed: "exact",
|
||||
rerankApplied: false,
|
||||
fallbackReason: "No embedding source available, fell back to FTS5.",
|
||||
},
|
||||
totalTokensUsed: state.memories.length * 24,
|
||||
budgetMaxTokens: 2000,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/memory/reindex
|
||||
await page.route(/\/api\/memory\/reindex$/, async (route) => {
|
||||
state.reindexCalls += 1;
|
||||
await fulfillJson(route, { started: true, pending: 0, queued: 0 });
|
||||
});
|
||||
|
||||
// GET/PUT /api/settings/memory
|
||||
await page.route(/\/api\/settings\/memory$/, async (route) => {
|
||||
const method = route.request().method();
|
||||
if (method === "GET") {
|
||||
await fulfillJson(route, state.settings);
|
||||
return;
|
||||
}
|
||||
if (method === "PUT") {
|
||||
state.settingsCalls += 1;
|
||||
const body = route.request().postDataJSON() as Partial<MemorySettings>;
|
||||
state.settings = { ...state.settings, ...body };
|
||||
await fulfillJson(route, state.settings);
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, { error: { message: "Method not allowed" } }, 405);
|
||||
});
|
||||
|
||||
// GET/PUT /api/settings/qdrant (Engine tab, QdrantConfigCard)
|
||||
await page.route(/\/api\/settings\/qdrant$/, async (route) => {
|
||||
const method = route.request().method();
|
||||
if (method === "GET") {
|
||||
await fulfillJson(route, {
|
||||
enabled: false,
|
||||
host: "",
|
||||
port: 6333,
|
||||
collection: "omniroute_memory",
|
||||
embeddingModel: "openai/text-embedding-3-small",
|
||||
hasApiKey: false,
|
||||
apiKeyMasked: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (method === "PUT") {
|
||||
await fulfillJson(route, { enabled: false, host: "", port: 6333 });
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, { error: { message: "Method not allowed" } }, 405);
|
||||
});
|
||||
|
||||
// GET /api/settings/qdrant/health
|
||||
await page.route(/\/api\/settings\/qdrant\/health$/, async (route) => {
|
||||
await fulfillJson(route, { ok: false, latencyMs: 0, error: "Connection refused" });
|
||||
});
|
||||
|
||||
// GET /api/settings/qdrant/embedding-models
|
||||
await page.route(/\/api\/settings\/qdrant\/embedding-models$/, async (route) => {
|
||||
await fulfillJson(route, { models: [] });
|
||||
});
|
||||
|
||||
// GET /api/memory/health
|
||||
await page.route(/\/api\/memory\/health$/, async (route) => {
|
||||
await fulfillJson(route, { working: true, latencyMs: 5 });
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test suite
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Memory Engine Studio — /dashboard/memory", () => {
|
||||
test.setTimeout(600_000);
|
||||
|
||||
test("1. /dashboard/memory renders 3 tabs and concept card", async ({ page }) => {
|
||||
const state = {
|
||||
memories: [makeMemory()],
|
||||
stats: { totalEntries: 1, tokensUsed: 24, hitRate: 0.75, cacheStats: { hits: 3, misses: 1 } },
|
||||
settings: defaultSettings(),
|
||||
engineStatus: defaultEngineStatus(),
|
||||
createCalls: 0,
|
||||
updateCalls: 0,
|
||||
deleteCalls: 0,
|
||||
settingsCalls: 0,
|
||||
reindexCalls: 0,
|
||||
previewCalls: 0,
|
||||
};
|
||||
|
||||
await setupMemoryRoutes(page, state);
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/memory", { timeoutMs: NAVIGATION_TIMEOUT_MS });
|
||||
|
||||
// All 3 tabs should be visible
|
||||
await expect(page.getByTestId("tab-memories")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("tab-playground")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("tab-engine")).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("2. Memories tab renders table and Total card", async ({ page }) => {
|
||||
const state = {
|
||||
memories: [makeMemory()],
|
||||
stats: { totalEntries: 1, tokensUsed: 48, hitRate: 0.5, cacheStats: { hits: 1, misses: 1 } },
|
||||
settings: defaultSettings(),
|
||||
engineStatus: defaultEngineStatus(),
|
||||
createCalls: 0,
|
||||
updateCalls: 0,
|
||||
deleteCalls: 0,
|
||||
settingsCalls: 0,
|
||||
reindexCalls: 0,
|
||||
previewCalls: 0,
|
||||
};
|
||||
|
||||
await setupMemoryRoutes(page, state);
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/memory", { timeoutMs: NAVIGATION_TIMEOUT_MS });
|
||||
|
||||
// Wait for memories tab (active by default)
|
||||
await expect(page.getByTestId("tab-memories")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// The memory key should appear in the table
|
||||
await expect(async () => {
|
||||
await expect(page.getByText("test.preference.language")).toBeVisible({ timeout: 10_000 });
|
||||
}).toPass({ timeout: 30_000, intervals: [1000, 2000] });
|
||||
});
|
||||
|
||||
test("3. Add Memory modal → entry appears in table", async ({ page }) => {
|
||||
const state = {
|
||||
memories: [] as MemoryEntry[],
|
||||
stats: { totalEntries: 0, tokensUsed: 0, hitRate: 0, cacheStats: { hits: 0, misses: 0 } },
|
||||
settings: defaultSettings(),
|
||||
engineStatus: defaultEngineStatus(),
|
||||
createCalls: 0,
|
||||
updateCalls: 0,
|
||||
deleteCalls: 0,
|
||||
settingsCalls: 0,
|
||||
reindexCalls: 0,
|
||||
previewCalls: 0,
|
||||
};
|
||||
|
||||
await setupMemoryRoutes(page, state);
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/memory", { timeoutMs: NAVIGATION_TIMEOUT_MS });
|
||||
|
||||
// Wait for the page to be ready (empty state is shown initially)
|
||||
await expect(page.getByTestId("tab-memories")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Click Add Memory
|
||||
await expect(async () => {
|
||||
const addBtn = page.getByRole("button", { name: /add memory/i }).first();
|
||||
await expect(addBtn).toBeVisible({ timeout: 10_000 });
|
||||
await addBtn.click();
|
||||
}).toPass({ timeout: 30_000, intervals: [1000, 2000] });
|
||||
|
||||
// Fill in the add-memory form fields
|
||||
const keyInput = page.getByPlaceholder(/e\.g.*preferences/i).or(page.getByLabel(/key/i)).first();
|
||||
await expect(keyInput).toBeVisible({ timeout: 10_000 });
|
||||
await keyInput.fill("test.new.memory");
|
||||
|
||||
const contentInput = page
|
||||
.getByPlaceholder(/content|value/i)
|
||||
.or(page.getByLabel(/content/i))
|
||||
.first();
|
||||
await expect(contentInput).toBeVisible({ timeout: 5_000 });
|
||||
await contentInput.fill("New memory added via modal.");
|
||||
|
||||
// Submit the form
|
||||
const saveBtn = page.getByRole("button", { name: /save|add|create/i }).last();
|
||||
await expect(saveBtn).toBeVisible({ timeout: 5_000 });
|
||||
await saveBtn.click();
|
||||
|
||||
// createCalls should increment
|
||||
await expect.poll(() => state.createCalls).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("4. Edit memory — pencil → modal → save → change reflected", async ({ page }) => {
|
||||
const mem = makeMemory({ id: "mem-edit-1", key: "edit.test.key", content: "Original content." });
|
||||
const state = {
|
||||
memories: [mem],
|
||||
stats: { totalEntries: 1, tokensUsed: 24, hitRate: 0.8, cacheStats: { hits: 4, misses: 1 } },
|
||||
settings: defaultSettings(),
|
||||
engineStatus: defaultEngineStatus(),
|
||||
createCalls: 0,
|
||||
updateCalls: 0,
|
||||
deleteCalls: 0,
|
||||
settingsCalls: 0,
|
||||
reindexCalls: 0,
|
||||
previewCalls: 0,
|
||||
};
|
||||
|
||||
await setupMemoryRoutes(page, state);
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/memory", { timeoutMs: NAVIGATION_TIMEOUT_MS });
|
||||
|
||||
// Wait for the memory to appear
|
||||
await expect(async () => {
|
||||
await expect(page.getByText("edit.test.key")).toBeVisible({ timeout: 10_000 });
|
||||
}).toPass({ timeout: 30_000, intervals: [1000, 2000] });
|
||||
|
||||
// Click the edit (pencil) button for our memory
|
||||
const editBtn = page.getByTestId(`edit-memory-${mem.id}`);
|
||||
await expect(editBtn).toBeVisible({ timeout: 10_000 });
|
||||
await editBtn.click();
|
||||
|
||||
// The edit modal should appear
|
||||
const contentInput = page.getByLabel(/content/i).or(page.locator("textarea")).first();
|
||||
await expect(contentInput).toBeVisible({ timeout: 10_000 });
|
||||
await contentInput.fill("Updated content via modal.");
|
||||
|
||||
// Save the changes
|
||||
const saveBtn = page.getByRole("button", { name: /save/i }).last();
|
||||
await expect(saveBtn).toBeVisible({ timeout: 5_000 });
|
||||
await saveBtn.click();
|
||||
|
||||
// updateCalls should increment
|
||||
await expect.poll(() => state.updateCalls).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("5. Playground tab — query and Simulate renders results", async ({ page }) => {
|
||||
const state = {
|
||||
memories: [makeMemory(), makeMemory({ id: "mem-2", key: "test.key.2", content: "Second fact." })],
|
||||
stats: { totalEntries: 2, tokensUsed: 48, hitRate: 0.6, cacheStats: { hits: 3, misses: 2 } },
|
||||
settings: defaultSettings(),
|
||||
engineStatus: defaultEngineStatus(),
|
||||
createCalls: 0,
|
||||
updateCalls: 0,
|
||||
deleteCalls: 0,
|
||||
settingsCalls: 0,
|
||||
reindexCalls: 0,
|
||||
previewCalls: 0,
|
||||
};
|
||||
|
||||
await setupMemoryRoutes(page, state);
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/memory", { timeoutMs: NAVIGATION_TIMEOUT_MS });
|
||||
|
||||
// Navigate to Playground tab
|
||||
await expect(page.getByTestId("tab-playground")).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("tab-playground").click();
|
||||
|
||||
// Fill in query
|
||||
const queryInput = page.getByTestId("playground-query-input");
|
||||
await expect(queryInput).toBeVisible({ timeout: 10_000 });
|
||||
await queryInput.fill("test");
|
||||
|
||||
// Click Simulate
|
||||
const submitBtn = page.getByTestId("playground-submit");
|
||||
await expect(submitBtn).toBeVisible({ timeout: 5_000 });
|
||||
await expect(submitBtn).toBeEnabled({ timeout: 5_000 });
|
||||
await submitBtn.click();
|
||||
|
||||
// Wait for previewCalls to increment
|
||||
await expect.poll(() => state.previewCalls).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Results section should appear (result count heading)
|
||||
await expect(
|
||||
page.getByText(/result\(s\)|resultado\(s\)/, { exact: false }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("6. Engine tab — status chips render and toggle transformers", async ({ page }) => {
|
||||
const state = {
|
||||
memories: [],
|
||||
stats: { totalEntries: 0, tokensUsed: 0, hitRate: 0, cacheStats: { hits: 0, misses: 0 } },
|
||||
settings: defaultSettings(),
|
||||
engineStatus: defaultEngineStatus(),
|
||||
createCalls: 0,
|
||||
updateCalls: 0,
|
||||
deleteCalls: 0,
|
||||
settingsCalls: 0,
|
||||
reindexCalls: 0,
|
||||
previewCalls: 0,
|
||||
};
|
||||
|
||||
await setupMemoryRoutes(page, state);
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/memory", { timeoutMs: NAVIGATION_TIMEOUT_MS });
|
||||
|
||||
// Navigate to Engine tab
|
||||
await expect(page.getByTestId("tab-engine")).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("tab-engine").click();
|
||||
|
||||
// Status section should be visible (Reindex Now button is a proxy for the engine panel)
|
||||
const reindexBtn = page.getByTestId("reindex-now-button");
|
||||
await expect(reindexBtn).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Engine status heading
|
||||
await expect(
|
||||
page.getByText(/engine status|status do engine/i, { exact: false }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("7. Reindex Now button triggers POST /api/memory/reindex", async ({ page }) => {
|
||||
const state = {
|
||||
memories: [],
|
||||
stats: { totalEntries: 0, tokensUsed: 0, hitRate: 0, cacheStats: { hits: 0, misses: 0 } },
|
||||
settings: defaultSettings(),
|
||||
engineStatus: { ...defaultEngineStatus(), vectorStore: { ...defaultEngineStatus().vectorStore, needsReindex: 5 } },
|
||||
createCalls: 0,
|
||||
updateCalls: 0,
|
||||
deleteCalls: 0,
|
||||
settingsCalls: 0,
|
||||
reindexCalls: 0,
|
||||
previewCalls: 0,
|
||||
};
|
||||
|
||||
await setupMemoryRoutes(page, state);
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/memory", { timeoutMs: NAVIGATION_TIMEOUT_MS });
|
||||
|
||||
// Navigate to Engine tab
|
||||
await expect(page.getByTestId("tab-engine")).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("tab-engine").click();
|
||||
|
||||
// Click Reindex Now
|
||||
const reindexBtn = page.getByTestId("reindex-now-button");
|
||||
await expect(reindexBtn).toBeVisible({ timeout: 20_000 });
|
||||
await reindexBtn.click();
|
||||
|
||||
// reindexCalls should increment (request was made)
|
||||
await expect.poll(() => state.reindexCalls).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
|
||||
|
||||
const NAVIGATION_TIMEOUT_MS = 300_000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
type QdrantSettings = {
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
collection: string;
|
||||
embeddingModel: string;
|
||||
hasApiKey: boolean;
|
||||
apiKeyMasked: string | null;
|
||||
};
|
||||
|
||||
type MemorySettings = {
|
||||
enabled: boolean;
|
||||
maxTokens: number;
|
||||
retentionDays: number;
|
||||
strategy: "recent" | "semantic" | "hybrid";
|
||||
skillsEnabled: boolean;
|
||||
embeddingSource: "remote" | "static" | "transformers" | "auto";
|
||||
embeddingProviderModel: string | null;
|
||||
transformersEnabled: boolean;
|
||||
staticEnabled: boolean;
|
||||
rerankEnabled: boolean;
|
||||
rerankProviderModel: string | null;
|
||||
vectorStore: "sqlite-vec" | "qdrant" | "auto";
|
||||
};
|
||||
|
||||
function defaultQdrantSettings(): QdrantSettings {
|
||||
return {
|
||||
enabled: false,
|
||||
host: "",
|
||||
port: 6333,
|
||||
collection: "omniroute_memory",
|
||||
embeddingModel: "openai/text-embedding-3-small",
|
||||
hasApiKey: false,
|
||||
apiKeyMasked: null,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultMemorySettings(): MemorySettings {
|
||||
return {
|
||||
enabled: true,
|
||||
maxTokens: 2000,
|
||||
retentionDays: 30,
|
||||
strategy: "hybrid",
|
||||
skillsEnabled: false,
|
||||
embeddingSource: "auto",
|
||||
embeddingProviderModel: null,
|
||||
transformersEnabled: false,
|
||||
staticEnabled: false,
|
||||
rerankEnabled: false,
|
||||
rerankProviderModel: null,
|
||||
vectorStore: "auto",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up all route mocks needed for the Engine tab + QdrantConfigCard.
|
||||
*
|
||||
* Key security assertion: health/search/cleanup endpoints return error payloads
|
||||
* WITHOUT a stack trace (no "at /…" lines) — validates Hard Rule #12 compliance.
|
||||
*/
|
||||
async function setupQdrantRoutes(
|
||||
page: Page,
|
||||
state: {
|
||||
qdrantSettings: QdrantSettings;
|
||||
memorySettings: MemorySettings;
|
||||
healthCalls: number;
|
||||
settingsPutCalls: number;
|
||||
searchCalls: number;
|
||||
cleanupCalls: number;
|
||||
},
|
||||
) {
|
||||
// /api/memory (GET) — empty list, needed by MemoriesTab which is the default
|
||||
await page.route(/\/api\/memory(\?.*)?$/, async (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
await fulfillJson(route, {
|
||||
data: [],
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
stats: { total: 0, tokensUsed: 0, hitRate: 0 },
|
||||
});
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, { error: { message: "Not allowed" } }, 405);
|
||||
});
|
||||
|
||||
// /api/memory/engine-status
|
||||
await page.route(/\/api\/memory\/engine-status$/, async (route) => {
|
||||
await fulfillJson(route, {
|
||||
keyword: { available: true, backend: "FTS5" },
|
||||
embedding: {
|
||||
source: null,
|
||||
model: null,
|
||||
dimensions: null,
|
||||
available: false,
|
||||
reason: "No embedding source",
|
||||
cacheStats: { hits: 0, misses: 0, size: 0 },
|
||||
},
|
||||
vectorStore: {
|
||||
backend: "none",
|
||||
available: false,
|
||||
rowCount: 0,
|
||||
needsReindex: 0,
|
||||
reason: "sqlite-vec unavailable",
|
||||
},
|
||||
qdrant: { enabled: false, healthy: null, latencyMs: null, error: null },
|
||||
rerank: {
|
||||
enabled: false,
|
||||
provider: null,
|
||||
model: null,
|
||||
available: false,
|
||||
reason: "Rerank disabled",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// /api/memory/embedding-providers
|
||||
await page.route(/\/api\/memory\/embedding-providers$/, async (route) => {
|
||||
await fulfillJson(route, { providers: [] });
|
||||
});
|
||||
|
||||
// /api/memory/health
|
||||
await page.route(/\/api\/memory\/health$/, async (route) => {
|
||||
await fulfillJson(route, { working: true, latencyMs: 5 });
|
||||
});
|
||||
|
||||
// /api/memory/reindex
|
||||
await page.route(/\/api\/memory\/reindex$/, async (route) => {
|
||||
await fulfillJson(route, { started: true, pending: 0 });
|
||||
});
|
||||
|
||||
// GET/PUT /api/settings/memory
|
||||
await page.route(/\/api\/settings\/memory$/, async (route) => {
|
||||
const method = route.request().method();
|
||||
if (method === "GET") {
|
||||
await fulfillJson(route, state.memorySettings);
|
||||
return;
|
||||
}
|
||||
if (method === "PUT") {
|
||||
const body = route.request().postDataJSON() as Partial<MemorySettings>;
|
||||
state.memorySettings = { ...state.memorySettings, ...body };
|
||||
await fulfillJson(route, state.memorySettings);
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, { error: { message: "Method not allowed" } }, 405);
|
||||
});
|
||||
|
||||
// GET/PUT /api/settings/qdrant
|
||||
await page.route(/\/api\/settings\/qdrant$/, async (route) => {
|
||||
const method = route.request().method();
|
||||
if (method === "GET") {
|
||||
await fulfillJson(route, state.qdrantSettings);
|
||||
return;
|
||||
}
|
||||
if (method === "PUT") {
|
||||
state.settingsPutCalls += 1;
|
||||
const body = route.request().postDataJSON() as Partial<QdrantSettings>;
|
||||
state.qdrantSettings = {
|
||||
...state.qdrantSettings,
|
||||
...body,
|
||||
// PUT returns the sanitized version (no raw apiKey field)
|
||||
};
|
||||
await fulfillJson(route, {
|
||||
...state.qdrantSettings,
|
||||
hasApiKey: false,
|
||||
apiKeyMasked: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, { error: { message: "Method not allowed" } }, 405);
|
||||
});
|
||||
|
||||
// GET /api/settings/qdrant/health — simulates a refused connection.
|
||||
// The error message MUST NOT contain a stack trace (Hard Rule #12).
|
||||
await page.route(/\/api\/settings\/qdrant\/health$/, async (route) => {
|
||||
state.healthCalls += 1;
|
||||
// Return a structured error that is safe (no stack trace)
|
||||
await fulfillJson(route, {
|
||||
ok: false,
|
||||
latencyMs: 0,
|
||||
error: "connect ECONNREFUSED 127.0.0.1:6333",
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/settings/qdrant/embedding-models
|
||||
await page.route(/\/api\/settings\/qdrant\/embedding-models$/, async (route) => {
|
||||
await fulfillJson(route, { models: ["openai/text-embedding-3-small"] });
|
||||
});
|
||||
|
||||
// POST /api/settings/qdrant/search — simulates failed search (Qdrant not running)
|
||||
await page.route(/\/api\/settings\/qdrant\/search$/, async (route) => {
|
||||
state.searchCalls += 1;
|
||||
await fulfillJson(
|
||||
route,
|
||||
{
|
||||
error: {
|
||||
message: "Qdrant connection failed: ECONNREFUSED",
|
||||
code: "QDRANT_UNAVAILABLE",
|
||||
},
|
||||
},
|
||||
503,
|
||||
);
|
||||
});
|
||||
|
||||
// POST /api/settings/qdrant/cleanup — simulates failed cleanup (Qdrant not running)
|
||||
await page.route(/\/api\/settings\/qdrant\/cleanup$/, async (route) => {
|
||||
state.cleanupCalls += 1;
|
||||
await fulfillJson(
|
||||
route,
|
||||
{
|
||||
error: {
|
||||
message: "Qdrant cleanup failed: ECONNREFUSED",
|
||||
code: "QDRANT_UNAVAILABLE",
|
||||
},
|
||||
},
|
||||
503,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Memory Qdrant routes — Engine tab integration", () => {
|
||||
test.setTimeout(600_000);
|
||||
|
||||
test("Engine tab renders Qdrant config card", async ({ page }) => {
|
||||
const state = {
|
||||
qdrantSettings: defaultQdrantSettings(),
|
||||
memorySettings: defaultMemorySettings(),
|
||||
healthCalls: 0,
|
||||
settingsPutCalls: 0,
|
||||
searchCalls: 0,
|
||||
cleanupCalls: 0,
|
||||
};
|
||||
|
||||
await setupQdrantRoutes(page, state);
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/memory", { timeoutMs: NAVIGATION_TIMEOUT_MS });
|
||||
|
||||
// Navigate to Engine tab
|
||||
await expect(page.getByTestId("tab-engine")).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("tab-engine").click();
|
||||
|
||||
// Qdrant section heading should be visible.
|
||||
// getByText(/qdrant/i) resolves to multiple elements (label, description, title, etc.),
|
||||
// causing a strict-mode violation. Use the unambiguous card heading instead.
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /qdrant/i }),
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Qdrant enabled switch should be visible
|
||||
const qdrantSwitch = page.getByTestId("qdrant-enabled-switch");
|
||||
await expect(qdrantSwitch).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("Test Connection button triggers GET /api/settings/qdrant/health with sanitized error", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = {
|
||||
qdrantSettings: { ...defaultQdrantSettings(), host: "localhost" },
|
||||
memorySettings: defaultMemorySettings(),
|
||||
healthCalls: 0,
|
||||
settingsPutCalls: 0,
|
||||
searchCalls: 0,
|
||||
cleanupCalls: 0,
|
||||
};
|
||||
|
||||
await setupQdrantRoutes(page, state);
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/memory", { timeoutMs: NAVIGATION_TIMEOUT_MS });
|
||||
|
||||
// Navigate to Engine tab
|
||||
await expect(page.getByTestId("tab-engine")).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("tab-engine").click();
|
||||
|
||||
// Find and click the Test Connection button
|
||||
const testConnBtn = page.getByTestId("qdrant-test-connection");
|
||||
await expect(testConnBtn).toBeVisible({ timeout: 20_000 });
|
||||
await testConnBtn.click();
|
||||
|
||||
// healthCalls should increment
|
||||
await expect.poll(() => state.healthCalls).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// The error should surface in the UI — but without a stack trace
|
||||
// "ECONNREFUSED" is acceptable; "at /" (stack trace marker) is not
|
||||
await expect(async () => {
|
||||
const bodyText = await page.locator("body").innerText();
|
||||
// Error is shown (connection refused, not just silent failure)
|
||||
expect(
|
||||
bodyText.toLowerCase().includes("error") ||
|
||||
bodyText.toLowerCase().includes("refused") ||
|
||||
bodyText.toLowerCase().includes("failed") ||
|
||||
bodyText.toLowerCase().includes("erro"),
|
||||
).toBe(true);
|
||||
// Must NOT contain a stack trace
|
||||
expect(bodyText).not.toMatch(/\sat\s\//);
|
||||
}).toPass({ timeout: 15_000, intervals: [1000, 2000] });
|
||||
});
|
||||
|
||||
test("Cleanup button triggers POST /api/settings/qdrant/cleanup with sanitized error", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = {
|
||||
qdrantSettings: { ...defaultQdrantSettings(), host: "localhost" },
|
||||
memorySettings: defaultMemorySettings(),
|
||||
healthCalls: 0,
|
||||
settingsPutCalls: 0,
|
||||
searchCalls: 0,
|
||||
cleanupCalls: 0,
|
||||
};
|
||||
|
||||
await setupQdrantRoutes(page, state);
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/memory", { timeoutMs: NAVIGATION_TIMEOUT_MS });
|
||||
|
||||
// Navigate to Engine tab
|
||||
await expect(page.getByTestId("tab-engine")).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("tab-engine").click();
|
||||
|
||||
// Find and click the Cleanup button
|
||||
const cleanupBtn = page.getByTestId("qdrant-cleanup");
|
||||
await expect(cleanupBtn).toBeVisible({ timeout: 20_000 });
|
||||
await cleanupBtn.click();
|
||||
|
||||
// cleanupCalls should increment
|
||||
await expect.poll(() => state.cleanupCalls).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// The error should surface in the UI but without a stack trace
|
||||
await expect(async () => {
|
||||
const bodyText = await page.locator("body").innerText();
|
||||
// Error surfaces
|
||||
expect(
|
||||
bodyText.toLowerCase().includes("error") ||
|
||||
bodyText.toLowerCase().includes("failed") ||
|
||||
bodyText.toLowerCase().includes("falh") ||
|
||||
bodyText.toLowerCase().includes("cleanup"),
|
||||
).toBe(true);
|
||||
// Must NOT contain a stack trace
|
||||
expect(bodyText).not.toMatch(/\sat\s\//);
|
||||
}).toPass({ timeout: 15_000, intervals: [1000, 2000] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
|
||||
|
||||
const NAVIGATION_TIMEOUT_MS = 300_000;
|
||||
|
||||
type MemoryConfig = {
|
||||
enabled: boolean;
|
||||
maxTokens: number;
|
||||
retentionDays: number;
|
||||
strategy: "recent" | "semantic" | "hybrid";
|
||||
skillsEnabled: boolean;
|
||||
};
|
||||
|
||||
type MemoryEntry = {
|
||||
id: string;
|
||||
apiKeyId: string;
|
||||
sessionId: string | null;
|
||||
type: "factual" | "episodic" | "procedural" | "semantic";
|
||||
key: string;
|
||||
content: string;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function setRangeValue(page: Page, testId: string, value: number) {
|
||||
await page.getByTestId(testId).evaluate((element, nextValue) => {
|
||||
const input = element as HTMLInputElement;
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
valueSetter?.call(input, String(nextValue));
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}, value);
|
||||
}
|
||||
|
||||
test.describe("Memory settings", () => {
|
||||
test.setTimeout(600_000);
|
||||
|
||||
test("updates memory config in settings and deletes stored memory entries", async ({ page }) => {
|
||||
const state: {
|
||||
config: MemoryConfig;
|
||||
settings: { skillsmpApiKey: string };
|
||||
memories: MemoryEntry[];
|
||||
updateCalls: number;
|
||||
deleteCalls: number;
|
||||
} = {
|
||||
config: {
|
||||
enabled: false,
|
||||
maxTokens: 2000,
|
||||
retentionDays: 30,
|
||||
strategy: "hybrid",
|
||||
skillsEnabled: false,
|
||||
},
|
||||
settings: {
|
||||
skillsmpApiKey: "",
|
||||
},
|
||||
memories: [
|
||||
{
|
||||
id: "mem-1",
|
||||
apiKeyId: "key-1",
|
||||
sessionId: "session-a",
|
||||
type: "factual",
|
||||
key: "preferred_language",
|
||||
content: "The user prefers answers in Portuguese.",
|
||||
metadata: {},
|
||||
createdAt: new Date("2026-04-05T20:00:00.000Z").toISOString(),
|
||||
updatedAt: new Date("2026-04-05T20:00:00.000Z").toISOString(),
|
||||
expiresAt: null,
|
||||
},
|
||||
],
|
||||
updateCalls: 0,
|
||||
deleteCalls: 0,
|
||||
};
|
||||
|
||||
await page.route(/\/api\/settings$/, async (route) => {
|
||||
const method = route.request().method();
|
||||
if (method === "GET") {
|
||||
await fulfillJson(route, state.settings);
|
||||
return;
|
||||
}
|
||||
if (method === "PATCH") {
|
||||
const payload = (route.request().postDataJSON() as Record<string, unknown>) || {};
|
||||
state.settings = {
|
||||
...state.settings,
|
||||
...(typeof payload.skillsmpApiKey === "string"
|
||||
? { skillsmpApiKey: payload.skillsmpApiKey }
|
||||
: {}),
|
||||
};
|
||||
await fulfillJson(route, state.settings);
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, { error: "Method not allowed in settings stub" }, 405);
|
||||
});
|
||||
|
||||
await page.route(/\/api\/settings\/memory$/, async (route) => {
|
||||
const method = route.request().method();
|
||||
if (method === "GET") {
|
||||
await fulfillJson(route, state.config);
|
||||
return;
|
||||
}
|
||||
if (method === "PUT") {
|
||||
state.updateCalls += 1;
|
||||
const payload = (route.request().postDataJSON() as Partial<MemoryConfig>) || {};
|
||||
state.config = {
|
||||
...state.config,
|
||||
...payload,
|
||||
};
|
||||
await fulfillJson(route, state.config);
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, { error: "Method not allowed in memory settings stub" }, 405);
|
||||
});
|
||||
|
||||
await page.route(/\/api\/memory(?:\?.*)?$/, async (route) => {
|
||||
await fulfillJson(route, {
|
||||
data: state.memories,
|
||||
total: state.memories.length,
|
||||
totalPages: 1,
|
||||
stats: {
|
||||
total: state.memories.length,
|
||||
tokensUsed: state.memories.length * 24,
|
||||
hitRate: state.memories.length > 0 ? 0.75 : 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(/\/api\/memory\/[^/]+$/, async (route) => {
|
||||
state.deleteCalls += 1;
|
||||
const memoryId = route.request().url().split("/").pop() || "";
|
||||
state.memories = state.memories.filter((memory) => memory.id !== memoryId);
|
||||
await fulfillJson(route, { success: true });
|
||||
});
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/settings/ai", {
|
||||
timeoutMs: NAVIGATION_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
let settingsHydrationRetries = 0;
|
||||
await expect(async () => {
|
||||
if (settingsHydrationRetries++ > 0) {
|
||||
await page.reload({ waitUntil: "commit" }).catch(() => {});
|
||||
}
|
||||
await expect(page.getByTestId("memory-enabled-switch")).toBeVisible({ timeout: 15000 });
|
||||
}).toPass({ timeout: 45_000, intervals: [1000, 2500, 5000] });
|
||||
await expect(page.getByTestId("memory-enabled-switch")).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false"
|
||||
);
|
||||
|
||||
await page.getByTestId("memory-enabled-switch").click();
|
||||
await expect(page.getByTestId("memory-enabled-switch")).toHaveAttribute("aria-checked", "true");
|
||||
await expect.poll(() => state.config.enabled).toBe(true);
|
||||
|
||||
await setRangeValue(page, "memory-retention-slider", 45);
|
||||
await expect.poll(() => state.config.retentionDays).toBe(45);
|
||||
|
||||
await page.getByTestId("memory-strategy-recent").click();
|
||||
await expect.poll(() => state.config.strategy).toBe("recent");
|
||||
await expect.poll(() => state.updateCalls).toBeGreaterThanOrEqual(3);
|
||||
|
||||
await page.getByTestId("memory-enabled-switch").click();
|
||||
await expect(page.getByTestId("memory-enabled-switch")).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false"
|
||||
);
|
||||
await expect.poll(() => state.config.enabled).toBe(false);
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/memory", {
|
||||
timeoutMs: NAVIGATION_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
let memoryHydrationRetries = 0;
|
||||
await expect(async () => {
|
||||
if (memoryHydrationRetries++ > 0) {
|
||||
await page.reload({ waitUntil: "commit" }).catch(() => {});
|
||||
}
|
||||
await expect(page.getByText("preferred_language")).toBeVisible({ timeout: 15000 });
|
||||
}).toPass({ timeout: 45_000, intervals: [1000, 2500, 5000] });
|
||||
await page.getByRole("button", { name: /delete/i }).click();
|
||||
|
||||
await expect.poll(() => state.deleteCalls).toBe(1);
|
||||
await expect(page.getByText("preferred_language")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Dashboard Navigation", () => {
|
||||
test("redirects unauthenticated user to /login", async ({ page }) => {
|
||||
const response = await page.goto("/dashboard");
|
||||
// Should either show login page or redirect to /login
|
||||
await page.waitForURL(/\/(login|dashboard)/);
|
||||
const url = page.url();
|
||||
// The app should show some kind of page (login or dashboard)
|
||||
expect(url).toMatch(/\/(login|dashboard)/);
|
||||
});
|
||||
|
||||
test("login page renders with form elements", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
// Should show some form of authentication UI
|
||||
const body = page.locator("body");
|
||||
await expect(body).toBeVisible();
|
||||
});
|
||||
|
||||
test("/docs page renders documentation", async ({ page }) => {
|
||||
await page.goto("/docs");
|
||||
const body = page.locator("body");
|
||||
await expect(body).toBeVisible();
|
||||
// Docs should contain some content
|
||||
const text = await body.textContent();
|
||||
expect(text?.length).toBeGreaterThan(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
|
||||
|
||||
test.describe("Playground Compare Tab", () => {
|
||||
function buildSseResponse(content: string, model: string): string {
|
||||
return [
|
||||
`data: ${JSON.stringify({ id: "cmp-1", object: "chat.completion.chunk", model, choices: [{ delta: { role: "assistant", content }, index: 0, finish_reason: null }] })}`,
|
||||
`data: ${JSON.stringify({ id: "cmp-1", object: "chat.completion.chunk", model, choices: [{ delta: {}, index: 0, finish_reason: "stop" }], usage: { prompt_tokens: 5, completion_tokens: 3 } })}`,
|
||||
"data: [DONE]",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Mock presets
|
||||
await page.route("**/api/playground/presets", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ presets: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
// Mock chat completions with SSE response
|
||||
let callCount = 0;
|
||||
await page.route("**/api/v1/chat/completions", async (route) => {
|
||||
callCount += 1;
|
||||
const model = callCount % 2 === 0 ? "claude-3-haiku" : "openai/gpt-4o-mini";
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: buildSseResponse(`Response from ${model}`, model),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("navigates to compare tab via URL param", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/playground?tab=compare");
|
||||
|
||||
// Compare tab should be visible and active
|
||||
const compareTab = page.getByRole("tab", { name: /compare/i });
|
||||
await expect(compareTab).toBeVisible({ timeout: 15000 });
|
||||
await expect(compareTab).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
test("can add two columns in Compare tab", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/playground");
|
||||
|
||||
// Navigate to Compare tab
|
||||
const compareTab = page.getByRole("tab", { name: /compare/i });
|
||||
await expect(compareTab).toBeVisible({ timeout: 15000 });
|
||||
await compareTab.click();
|
||||
|
||||
// Get the Add model button
|
||||
const addButton = page.getByRole("button", { name: /add model/i });
|
||||
await expect(addButton).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Type a model name in the input and add it
|
||||
const modelInput = page.locator('input[placeholder*="Model"], input[aria-label*="model" i]').first();
|
||||
await expect(modelInput).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Add first column
|
||||
await modelInput.fill("openai/gpt-4o-mini");
|
||||
await addButton.click();
|
||||
|
||||
// Wait a moment for the column to appear
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Add second column
|
||||
await modelInput.fill("claude-3-haiku");
|
||||
await addButton.click();
|
||||
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Both columns should be visible (each column shows a model name or remove button)
|
||||
const removeButtons = page.getByRole("button", { name: /remove column/i });
|
||||
// There should be at least 2 remove buttons (one per column)
|
||||
const count = await removeButtons.count();
|
||||
expect(count).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("Run all button is visible when there are columns", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/playground");
|
||||
|
||||
const compareTab = page.getByRole("tab", { name: /compare/i });
|
||||
await expect(compareTab).toBeVisible({ timeout: 15000 });
|
||||
await compareTab.click();
|
||||
|
||||
// Add a column
|
||||
const addButton = page.getByRole("button", { name: /add model/i });
|
||||
await expect(addButton).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const modelInput = page.locator('input[placeholder*="Model"], input[aria-label*="model" i]').first();
|
||||
await expect(modelInput).toBeVisible({ timeout: 10000 });
|
||||
await modelInput.fill("openai/gpt-4o");
|
||||
await addButton.click();
|
||||
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Run all button should be visible
|
||||
const runAllButton = page.getByRole("button", { name: /run all/i });
|
||||
await expect(runAllButton).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("Cancel all button is visible and clickable", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/playground");
|
||||
|
||||
const compareTab = page.getByRole("tab", { name: /compare/i });
|
||||
await expect(compareTab).toBeVisible({ timeout: 15000 });
|
||||
await compareTab.click();
|
||||
|
||||
// Wait for CompareTab to finish loading (it's a dynamic import with ssr: false).
|
||||
// The "Add model column" button is always rendered once the component mounts and
|
||||
// provides a reliable hydration signal before we check the Run/Cancel toolbar.
|
||||
await expect(page.getByRole("button", { name: /add model/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// The toolbar shows "Run all" when idle and "Cancel all" when streaming —
|
||||
// they are mutually exclusive. Verify the toolbar control is always present
|
||||
// by checking that exactly one of the two buttons is visible.
|
||||
// (CompareTab.tsx renders <button aria-label="Run all columns"> or
|
||||
// <button aria-label="Cancel all streams"> based on isAnyStreaming state.)
|
||||
const runAllButton = page.getByRole("button", { name: /run all/i });
|
||||
const cancelButton = page.getByRole("button", { name: /cancel all|abort/i });
|
||||
// Use expect().toBeVisible() instead of isVisible() — the latter does not wait
|
||||
// in Playwright 1.50+ (timeout option is deprecated/ignored on locator.isVisible).
|
||||
const hasRunAll = await expect(runAllButton)
|
||||
.toBeVisible({ timeout: 5000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
const hasCancel = await expect(cancelButton)
|
||||
.toBeVisible({ timeout: 1000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
expect(hasRunAll || hasCancel).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
|
||||
|
||||
test.describe("Playground Studio", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Mock the playground presets API so it does not require DB
|
||||
await page.route("**/api/playground/presets", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ presets: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
// Mock /v1/chat/completions with a streaming response for the Chat tab test
|
||||
await page.route("**/api/v1/chat/completions", async (route) => {
|
||||
const sseBody = [
|
||||
`data: ${JSON.stringify({ id: "chatcmpl-1", object: "chat.completion.chunk", model: "test-model", choices: [{ delta: { role: "assistant", content: "Hello" }, index: 0, finish_reason: null }] })}`,
|
||||
`data: ${JSON.stringify({ id: "chatcmpl-1", object: "chat.completion.chunk", model: "test-model", choices: [{ delta: { content: ", world!" }, index: 0, finish_reason: null }] })}`,
|
||||
`data: ${JSON.stringify({ id: "chatcmpl-1", object: "chat.completion.chunk", model: "test-model", choices: [{ delta: {}, index: 0, finish_reason: "stop" }], usage: { prompt_tokens: 10, completion_tokens: 5 } })}`,
|
||||
"data: [DONE]",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: sseBody,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("loads page and shows 4 tabs", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/playground");
|
||||
|
||||
// Wait for the playground page to load
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
|
||||
// The Studio should render 4 tabs in a tablist
|
||||
const tablist = page.getByRole("tablist").first();
|
||||
await expect(tablist).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Check that all 4 tabs are present
|
||||
const chatTab = page.getByRole("tab", { name: /chat/i });
|
||||
const compareTab = page.getByRole("tab", { name: /compare/i });
|
||||
const apiTab = page.getByRole("tab", { name: /api/i });
|
||||
const buildTab = page.getByRole("tab", { name: /build/i });
|
||||
|
||||
await expect(chatTab).toBeVisible({ timeout: 15000 });
|
||||
await expect(compareTab).toBeVisible({ timeout: 15000 });
|
||||
await expect(apiTab).toBeVisible({ timeout: 15000 });
|
||||
await expect(buildTab).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
test("switches to Compare tab and shows Add model button", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/playground");
|
||||
|
||||
// Wait for tabs
|
||||
const compareTab = page.getByRole("tab", { name: /compare/i });
|
||||
await expect(compareTab).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Click Compare tab
|
||||
await compareTab.click();
|
||||
|
||||
// The Compare tab should show an "Add model" button
|
||||
const addModelButton = page.getByRole("button", { name: /add model/i });
|
||||
await expect(addModelButton).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("switches from Compare back to Chat tab", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/playground");
|
||||
|
||||
const compareTab = page.getByRole("tab", { name: /compare/i });
|
||||
await expect(compareTab).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Go to Compare
|
||||
await compareTab.click();
|
||||
|
||||
// Go back to Chat
|
||||
const chatTab = page.getByRole("tab", { name: /chat/i });
|
||||
await chatTab.click();
|
||||
|
||||
// Chat tab should be active
|
||||
await expect(chatTab).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
test("Chat tab renders a message send area", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/playground");
|
||||
|
||||
// Wait for the studio to load
|
||||
const chatTab = page.getByRole("tab", { name: /chat/i });
|
||||
await expect(chatTab).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Chat tab should have a message input / send area
|
||||
const textarea = page
|
||||
.locator("textarea")
|
||||
.filter({ hasText: "" })
|
||||
.first();
|
||||
await expect(textarea).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import { beforeAll, describe, it, expect } from "vitest";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
|
||||
const BASE_URL = process.env.OMNIROUTE_BASE_URL || "http://localhost:20128";
|
||||
const API_KEY = process.env.OMNIROUTE_API_KEY || "";
|
||||
const REQUEST_TIMEOUT_MS = Number(process.env.ECOSYSTEM_REQUEST_TIMEOUT_MS || 30000);
|
||||
const TEST_TIMEOUT_MS = Number(process.env.ECOSYSTEM_TEST_TIMEOUT_MS || 60000);
|
||||
|
||||
function headers(extra?: Record<string, string>) {
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}),
|
||||
...(extra || {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function apiFetch(path: string, options?: RequestInit) {
|
||||
return fetch(`${BASE_URL}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...headers(),
|
||||
...(options?.headers || {}),
|
||||
},
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
}
|
||||
|
||||
async function callA2A(method: string, params: Record<string, unknown>, id: string) {
|
||||
const response = await apiFetch("/a2a", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
}),
|
||||
});
|
||||
const json = await response.json().catch(() => ({}));
|
||||
return { response, json };
|
||||
}
|
||||
|
||||
async function consumeA2AStream(response: Response): Promise<{
|
||||
taskId: string | null;
|
||||
terminalState: string | null;
|
||||
chunks: number;
|
||||
}> {
|
||||
if (!response.body) return { taskId: null, terminalState: null, chunks: 0 };
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let taskId: string | null = null;
|
||||
let terminalState: string | null = null;
|
||||
let chunks = 0;
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const events = buffer.split("\n\n");
|
||||
buffer = events.pop() || "";
|
||||
|
||||
for (const event of events) {
|
||||
if (!event.startsWith("data: ")) continue;
|
||||
const payload = event.slice("data: ".length);
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(payload);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const nextTaskId = parsed?.params?.task?.id;
|
||||
const nextState = parsed?.params?.task?.state;
|
||||
if (nextTaskId) taskId = nextTaskId;
|
||||
if (parsed?.params?.chunk) chunks += 1;
|
||||
if (
|
||||
typeof nextState === "string" &&
|
||||
["completed", "failed", "cancelled"].includes(nextState)
|
||||
) {
|
||||
terminalState = nextState;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { taskId, terminalState, chunks };
|
||||
}
|
||||
|
||||
describe("Protocol clients E2E", () => {
|
||||
beforeAll(async () => {
|
||||
const response = await apiFetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ a2aEnabled: true }),
|
||||
});
|
||||
expect([200, 401]).toContain(response.status);
|
||||
});
|
||||
|
||||
it(
|
||||
"connects via MCP stdio and invokes required tools",
|
||||
async () => {
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: ["--import", "tsx", "open-sse/mcp-server/server.ts"],
|
||||
env: {
|
||||
...process.env,
|
||||
OMNIROUTE_BASE_URL: BASE_URL,
|
||||
OMNIROUTE_API_KEY: API_KEY,
|
||||
} as Record<string, string>,
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const client = new Client({ name: "protocol-e2e", version: "1.0.0" });
|
||||
await client.connect(transport);
|
||||
|
||||
try {
|
||||
const listed = await client.listTools();
|
||||
const toolNames = listed.tools.map((tool) => tool.name);
|
||||
expect(toolNames).toContain("omniroute_get_health");
|
||||
expect(toolNames).toContain("omniroute_list_combos");
|
||||
|
||||
const healthResult = await client.callTool({
|
||||
name: "omniroute_get_health",
|
||||
arguments: {},
|
||||
});
|
||||
expect(Array.isArray(healthResult.content)).toBe(true);
|
||||
|
||||
const combosResult = await client.callTool({
|
||||
name: "omniroute_list_combos",
|
||||
arguments: { includeMetrics: false },
|
||||
});
|
||||
expect(Array.isArray(combosResult.content)).toBe(true);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
|
||||
const auditRes = await apiFetch("/api/mcp/audit?limit=50&tool=omniroute_get_health");
|
||||
expect([200, 401]).toContain(auditRes.status);
|
||||
if (auditRes.status === 200) {
|
||||
expect(auditRes.ok).toBe(true);
|
||||
const auditJson = (await auditRes.json()) as any;
|
||||
const entries = Array.isArray(auditJson?.entries) ? auditJson.entries : [];
|
||||
expect(entries.some((entry: any) => entry.toolName === "omniroute_get_health")).toBe(true);
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT_MS * 2
|
||||
);
|
||||
|
||||
it(
|
||||
"executes A2A discovery/send/stream/get/cancel flow",
|
||||
async () => {
|
||||
const cardRes = await apiFetch("/.well-known/agent.json");
|
||||
expect(cardRes.ok).toBe(true);
|
||||
const card = (await cardRes.json()) as any;
|
||||
expect(card).toHaveProperty("name");
|
||||
expect(Array.isArray(card?.skills)).toBe(true);
|
||||
|
||||
const send = await callA2A(
|
||||
"message/send",
|
||||
{
|
||||
skill: "quota-management",
|
||||
messages: [{ role: "user", content: "Return a short quota summary." }],
|
||||
},
|
||||
"protocol-send"
|
||||
);
|
||||
if (send.response.status === 401) {
|
||||
expect(API_KEY).toBe("");
|
||||
expect(send.json?.error).toBeTruthy();
|
||||
return;
|
||||
}
|
||||
expect(send.response.ok).toBe(true);
|
||||
expect(send.json?.error).toBeFalsy();
|
||||
const sendTaskId: string = send.json?.result?.task?.id;
|
||||
expect(typeof sendTaskId).toBe("string");
|
||||
|
||||
const streamRes = await apiFetch("/a2a", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: "protocol-stream",
|
||||
method: "message/stream",
|
||||
params: {
|
||||
skill: "quota-management",
|
||||
messages: [{ role: "user", content: "Stream a short quota summary." }],
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(streamRes.ok).toBe(true);
|
||||
expect(streamRes.headers.get("content-type") || "").toContain("text/event-stream");
|
||||
|
||||
const stream = await consumeA2AStream(streamRes);
|
||||
expect(typeof stream.taskId === "string" || stream.taskId === null).toBe(true);
|
||||
expect(
|
||||
stream.terminalState === null ||
|
||||
["completed", "failed", "cancelled"].includes(stream.terminalState)
|
||||
).toBe(true);
|
||||
|
||||
const taskIdForGet = stream.taskId || sendTaskId;
|
||||
const get = await callA2A("tasks/get", { taskId: taskIdForGet }, "protocol-get");
|
||||
expect(get.response.ok).toBe(true);
|
||||
expect(get.json?.result?.task?.id).toBe(taskIdForGet);
|
||||
|
||||
const cancelRes = await apiFetch(
|
||||
`/api/a2a/tasks/${encodeURIComponent(taskIdForGet)}/cancel`,
|
||||
{
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
expect([200, 400, 401, 404]).toContain(cancelRes.status);
|
||||
|
||||
const tasksRes = await apiFetch("/api/a2a/tasks?limit=50");
|
||||
expect([200, 401]).toContain(tasksRes.status);
|
||||
if (tasksRes.status === 200) {
|
||||
expect(tasksRes.ok).toBe(true);
|
||||
const tasksJson = (await tasksRes.json()) as any;
|
||||
const tasks = Array.isArray(tasksJson?.tasks) ? tasksJson.tasks : [];
|
||||
expect(tasks.some((task: any) => task.id === sendTaskId)).toBe(true);
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT_MS * 2
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
|
||||
|
||||
test.describe("Protocol visibility", () => {
|
||||
test("shows MCP and A2A tabs inside the endpoint page", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/endpoint");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// MCP and A2A are now tabs directly in the SegmentedControl
|
||||
const mcpTab = page.getByRole("tab", { name: "MCP" });
|
||||
const a2aTab = page.getByRole("tab", { name: "A2A" });
|
||||
|
||||
await expect(mcpTab).toBeVisible();
|
||||
await expect(a2aTab).toBeVisible();
|
||||
|
||||
// Verify MCP dashboard mounts
|
||||
await mcpTab.click();
|
||||
// In dev/test it might just show "loading..." or the processStatus card
|
||||
await expect(page.locator("body")).not.toContainText(/application error|500/i);
|
||||
|
||||
// Verify A2A dashboard mounts
|
||||
await a2aTab.click();
|
||||
await expect(page.locator("body")).not.toContainText(/application error|500/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
|
||||
|
||||
const DEFAULT_BAILIAN_URL = "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1";
|
||||
|
||||
test.describe("Bailian Coding Plan Provider", () => {
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test("default URL visible and editable in Add API Key modal", async ({ page }) => {
|
||||
const capturedPayloads: { createProvider?: Record<string, unknown> } = {};
|
||||
|
||||
await page.route("**/api/providers", async (route) => {
|
||||
const method = route.request().method();
|
||||
if (method === "GET") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ connections: [] }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST") {
|
||||
const payload = route.request().postDataJSON();
|
||||
capturedPayloads.createProvider = payload;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
connection: {
|
||||
id: "conn-bailian-test",
|
||||
provider: "bailian-coding-plan",
|
||||
name: payload.name || "Test Connection",
|
||||
testStatus: "active",
|
||||
providerSpecificData: payload.providerSpecificData,
|
||||
},
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({ status: 405 });
|
||||
});
|
||||
|
||||
await page.route("**/api/providers/validate", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ valid: true }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/provider-nodes", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ nodes: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/providers/bailian-coding-plan");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
|
||||
// Dismiss any pre-existing dialog/overlay that may appear on page load
|
||||
const preExistingDialog = page.getByRole("dialog").first();
|
||||
if (await preExistingDialog.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await page.keyboard.press("Escape");
|
||||
await preExistingDialog.waitFor({ state: "hidden", timeout: 3000 }).catch(() => {});
|
||||
}
|
||||
|
||||
const addKeyButton = page.getByRole("button", {
|
||||
name: /add.*api.*key|add.*key|add.*connection|connect|adicionar.*chave/i,
|
||||
});
|
||||
|
||||
// Wait for the button to appear instead of immediately checking visibility
|
||||
await addKeyButton.first().waitFor({ state: "visible", timeout: 15000 });
|
||||
await expect(addKeyButton.first()).toBeEnabled({ timeout: 5000 });
|
||||
await addKeyButton.first().click();
|
||||
|
||||
const dialog = page.getByRole("dialog").first();
|
||||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const baseUrlInput = dialog
|
||||
.getByLabel(/base.*url/i)
|
||||
.or(dialog.locator("input").filter({ has: page.locator("..").getByText(/base.*url/i) }));
|
||||
|
||||
await expect(baseUrlInput).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const inputValue = await baseUrlInput.inputValue();
|
||||
expect(inputValue).toBe(DEFAULT_BAILIAN_URL);
|
||||
|
||||
const nameInput = dialog.getByLabel(/name/i).or(dialog.locator("input").first());
|
||||
await nameInput.fill("Test Bailian Connection");
|
||||
|
||||
const apiKeyInput = dialog
|
||||
.getByLabel(/api.*key/i)
|
||||
.or(dialog.locator('input[type="password"]').first());
|
||||
await apiKeyInput.fill("test-api-key-12345");
|
||||
|
||||
const customUrl = "https://custom.example.com/anthropic/v1";
|
||||
await baseUrlInput.fill(customUrl);
|
||||
|
||||
const saveButton = dialog
|
||||
.getByRole("button", {
|
||||
name: /save|add|create|connect/i,
|
||||
})
|
||||
.last();
|
||||
await expect(saveButton).toBeEnabled({ timeout: 15000 });
|
||||
await saveButton.click();
|
||||
|
||||
await expect(dialog)
|
||||
.toBeHidden({ timeout: 10000 })
|
||||
.catch(() => undefined);
|
||||
|
||||
expect(capturedPayloads.createProvider).toBeDefined();
|
||||
const payload = capturedPayloads.createProvider;
|
||||
expect(payload?.providerSpecificData).toBeDefined();
|
||||
expect((payload?.providerSpecificData as Record<string, unknown>)?.baseUrl).toBe(customUrl);
|
||||
});
|
||||
|
||||
test("invalid URL blocks save with validation error", async ({ page }) => {
|
||||
let createAttempted = false;
|
||||
|
||||
await page.route("**/api/providers", async (route) => {
|
||||
const method = route.request().method();
|
||||
if (method === "GET") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ connections: [] }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST") {
|
||||
createAttempted = true;
|
||||
await route.fulfill({
|
||||
status: 400,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
message: "Invalid request",
|
||||
details: [
|
||||
{
|
||||
field: "providerSpecificData.baseUrl",
|
||||
message: "providerSpecificData.baseUrl must be a valid URL",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({ status: 405 });
|
||||
});
|
||||
|
||||
await page.route("**/api/providers/validate", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ valid: true }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/provider-nodes", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ nodes: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/providers/bailian-coding-plan");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
|
||||
// Dismiss any pre-existing dialog/overlay that may appear on page load
|
||||
const preExistingDialog = page.getByRole("dialog").first();
|
||||
if (await preExistingDialog.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await page.keyboard.press("Escape");
|
||||
await preExistingDialog.waitFor({ state: "hidden", timeout: 3000 }).catch(() => {});
|
||||
}
|
||||
|
||||
const addKeyButton = page.getByRole("button", {
|
||||
name: /add.*api.*key|add.*key|add.*connection|connect|adicionar.*chave/i,
|
||||
});
|
||||
|
||||
// Wait for the button to appear instead of immediately checking visibility
|
||||
await addKeyButton.first().waitFor({ state: "visible", timeout: 15000 });
|
||||
await expect(addKeyButton.first()).toBeEnabled({ timeout: 5000 });
|
||||
await addKeyButton.first().click();
|
||||
|
||||
const dialog = page.getByRole("dialog").first();
|
||||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const baseUrlInput = dialog
|
||||
.getByLabel(/base.*url/i)
|
||||
.or(dialog.locator("input").filter({ has: page.locator("..").getByText(/base.*url/i) }));
|
||||
await expect(baseUrlInput).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const nameInput = dialog.getByLabel(/name/i).or(dialog.locator("input").first());
|
||||
await nameInput.fill("Test Invalid URL Connection");
|
||||
|
||||
const apiKeyInput = dialog
|
||||
.getByLabel(/api.*key/i)
|
||||
.or(dialog.locator('input[type="password"]').first());
|
||||
await apiKeyInput.fill("test-api-key-12345");
|
||||
|
||||
await baseUrlInput.fill("not-a-url");
|
||||
|
||||
const saveButton = dialog
|
||||
.getByRole("button", {
|
||||
name: /save|add|create|connect/i,
|
||||
})
|
||||
.last();
|
||||
await saveButton.click();
|
||||
|
||||
// Wait for React to process the validation and re-render
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Check for the validation error scoped to the dialog to avoid strict-mode
|
||||
// violations from broad selectors matching unrelated page elements.
|
||||
const errorTextLocator = dialog
|
||||
.locator("text=/invalid.*url|url.*invalid|must be a valid url|must use http/i")
|
||||
.first();
|
||||
const errorClassLocator = dialog.locator(".text-red-500").first();
|
||||
|
||||
let errorVisible =
|
||||
(await errorTextLocator.isVisible().catch(() => false)) ||
|
||||
(await errorClassLocator.isVisible().catch(() => false));
|
||||
|
||||
if (!errorVisible) {
|
||||
// Fallback: if the dialog stays open after clicking save, it means the
|
||||
// client-side validation prevented submission (which is the desired behavior).
|
||||
await page.waitForTimeout(2000);
|
||||
errorVisible = await dialog.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
expect(errorVisible).toBe(true);
|
||||
expect(createAttempted).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
|
||||
|
||||
const NAVIGATION_TIMEOUT_MS = 300_000;
|
||||
|
||||
type ProviderConnection = {
|
||||
id: string;
|
||||
provider: string;
|
||||
name: string;
|
||||
authType: "api_key";
|
||||
isActive: boolean;
|
||||
testStatus: string;
|
||||
priority: number;
|
||||
providerSpecificData: Record<string, unknown>;
|
||||
lastError: string | null;
|
||||
lastErrorAt: string | null;
|
||||
lastErrorType: string | null;
|
||||
lastErrorSource: string | null;
|
||||
errorCode: string | null;
|
||||
rateLimitedUntil: string | null;
|
||||
};
|
||||
|
||||
async function installProviderFetchMock(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
const state = {
|
||||
connections: [] as ProviderConnection[],
|
||||
nextId: 1,
|
||||
retestCalls: 0,
|
||||
deleteCalls: 0,
|
||||
validationCalls: 0,
|
||||
forceInvalidValidation: false,
|
||||
};
|
||||
|
||||
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value));
|
||||
const jsonResponse = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
const readJsonBody = async (request: Request): Promise<Record<string, unknown>> => {
|
||||
try {
|
||||
const rawBody = await request.clone().text();
|
||||
if (!rawBody) return {};
|
||||
const parsed = JSON.parse(rawBody);
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
Object.defineProperty(window, "__providersTestState", {
|
||||
configurable: true,
|
||||
value: state,
|
||||
});
|
||||
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init);
|
||||
const url = new URL(request.url, window.location.origin);
|
||||
const method = request.method.toUpperCase();
|
||||
const path = url.pathname;
|
||||
|
||||
if (path === "/api/providers/expiration") {
|
||||
return jsonResponse({
|
||||
summary: { expired: 0, expiringSoon: 0 },
|
||||
list: [],
|
||||
});
|
||||
}
|
||||
|
||||
if (path === "/api/provider-nodes") {
|
||||
return jsonResponse({
|
||||
nodes: [],
|
||||
ccCompatibleProviderEnabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (path === "/api/models/alias") {
|
||||
if (method === "GET") {
|
||||
return jsonResponse({ aliases: {} });
|
||||
}
|
||||
return jsonResponse({ success: true });
|
||||
}
|
||||
|
||||
if (path === "/api/settings/proxy") {
|
||||
if (url.searchParams.has("resolve")) {
|
||||
return jsonResponse({ proxy: null, level: null });
|
||||
}
|
||||
return jsonResponse({ providers: {} });
|
||||
}
|
||||
|
||||
if (path === "/api/provider-models") {
|
||||
return jsonResponse({
|
||||
models: [],
|
||||
modelCompatOverrides: [],
|
||||
});
|
||||
}
|
||||
|
||||
if (path === "/api/rate-limits") {
|
||||
return jsonResponse({ providers: [] });
|
||||
}
|
||||
|
||||
if (path === "/api/providers/validate") {
|
||||
state.validationCalls += 1;
|
||||
const valid = !state.forceInvalidValidation;
|
||||
return jsonResponse({ valid }, valid ? 200 : 400);
|
||||
}
|
||||
|
||||
// Stub sync-models so the import modal reaches "done" immediately after adding a connection
|
||||
if (path.match(/^\/api\/providers\/[^/]+\/sync-models$/) && method === "POST") {
|
||||
return jsonResponse({ syncedModels: 0, models: [], availableModelsCount: 0 });
|
||||
}
|
||||
|
||||
const testMatch = path.match(/^\/api\/providers\/([^/]+)\/test$/);
|
||||
if (testMatch && method === "POST") {
|
||||
state.retestCalls += 1;
|
||||
const connectionId = testMatch[1];
|
||||
state.connections = state.connections.map((connection) =>
|
||||
connection.id === connectionId
|
||||
? {
|
||||
...connection,
|
||||
testStatus: "active",
|
||||
lastError: null,
|
||||
lastErrorAt: null,
|
||||
lastErrorType: null,
|
||||
lastErrorSource: null,
|
||||
errorCode: null,
|
||||
rateLimitedUntil: null,
|
||||
}
|
||||
: connection
|
||||
);
|
||||
return jsonResponse({ valid: true });
|
||||
}
|
||||
|
||||
const detailMatch = path.match(/^\/api\/providers\/([^/]+)$/);
|
||||
if (detailMatch) {
|
||||
const connectionId = detailMatch[1];
|
||||
|
||||
if (method === "PUT") {
|
||||
const payload = await readJsonBody(request);
|
||||
state.connections = state.connections.map((connection) =>
|
||||
connection.id === connectionId
|
||||
? {
|
||||
...connection,
|
||||
name:
|
||||
typeof payload.name === "string" && payload.name.trim()
|
||||
? payload.name.trim()
|
||||
: connection.name,
|
||||
priority:
|
||||
typeof payload.priority === "number" ? payload.priority : connection.priority,
|
||||
isActive:
|
||||
typeof payload.isActive === "boolean" ? payload.isActive : connection.isActive,
|
||||
providerSpecificData: {
|
||||
...connection.providerSpecificData,
|
||||
...(payload.providerSpecificData as Record<string, unknown> | undefined),
|
||||
...(typeof payload.tag === "string" ? { tag: payload.tag } : {}),
|
||||
...(typeof payload.validationModelId === "string"
|
||||
? { validationModelId: payload.validationModelId }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: connection
|
||||
);
|
||||
const updated = state.connections.find((connection) => connection.id === connectionId);
|
||||
return jsonResponse({ connection: clone(updated) });
|
||||
}
|
||||
|
||||
if (method === "DELETE") {
|
||||
state.deleteCalls += 1;
|
||||
state.connections = state.connections.filter(
|
||||
(connection) => connection.id !== connectionId
|
||||
);
|
||||
return jsonResponse({ success: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (path === "/api/providers") {
|
||||
if (method === "GET") {
|
||||
return jsonResponse({ connections: clone(state.connections) });
|
||||
}
|
||||
|
||||
if (method === "POST") {
|
||||
const payload = await readJsonBody(request);
|
||||
const apiKey = typeof payload.apiKey === "string" ? payload.apiKey : "";
|
||||
|
||||
if (!apiKey || apiKey.includes("invalid")) {
|
||||
return jsonResponse({ error: "Invalid API key" }, 400);
|
||||
}
|
||||
|
||||
const connection: ProviderConnection = {
|
||||
id: `conn-openai-${state.nextId++}`,
|
||||
provider: String(payload.provider || "openai"),
|
||||
name: String(payload.name || `OpenAI ${state.nextId}`),
|
||||
authType: "api_key",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
priority: typeof payload.priority === "number" ? payload.priority : 1,
|
||||
providerSpecificData: {
|
||||
tag: typeof payload.tag === "string" ? payload.tag : "",
|
||||
validationModelId:
|
||||
typeof payload.validationModelId === "string" ? payload.validationModelId : "",
|
||||
},
|
||||
lastError: null,
|
||||
lastErrorAt: null,
|
||||
lastErrorType: null,
|
||||
lastErrorSource: null,
|
||||
errorCode: null,
|
||||
rateLimitedUntil: null,
|
||||
};
|
||||
|
||||
state.connections.push(connection);
|
||||
return jsonResponse({ connection: clone(connection) });
|
||||
}
|
||||
}
|
||||
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function readProviderMockState(page: Page) {
|
||||
return page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as Window & {
|
||||
__providersTestState: {
|
||||
connections: ProviderConnection[];
|
||||
nextId: number;
|
||||
retestCalls: number;
|
||||
deleteCalls: number;
|
||||
validationCalls: number;
|
||||
forceInvalidValidation: boolean;
|
||||
};
|
||||
}
|
||||
).__providersTestState
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("Providers management", () => {
|
||||
test.setTimeout(600_000);
|
||||
|
||||
test("adds, edits, retests, deletes, and validates provider connections through the UI", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installProviderFetchMock(page);
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/providers", {
|
||||
timeoutMs: NAVIGATION_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const openAiCard = page.locator('a[href="/dashboard/providers/openai"]').first();
|
||||
await expect(openAiCard).toBeVisible();
|
||||
await openAiCard.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/providers\/openai$/);
|
||||
await page.getByRole("button", { name: /^add$/i }).first().click();
|
||||
|
||||
const addDialog = page.getByRole("dialog");
|
||||
await expect(addDialog).toBeVisible();
|
||||
await addDialog.getByLabel(/name/i).fill("Primary OpenAI");
|
||||
await addDialog.getByLabel(/api key/i).fill("sk-openai-valid");
|
||||
await addDialog.getByRole("button", { name: /^save$/i }).click();
|
||||
|
||||
await expect
|
||||
.poll(async () => (await readProviderMockState(page)).validationCalls)
|
||||
.toBeGreaterThan(0);
|
||||
await expect(page.getByText("Primary OpenAI")).toBeVisible();
|
||||
await expect.poll(async () => (await readProviderMockState(page)).connections.length).toBe(1);
|
||||
|
||||
// After save, the UI opens a model-import modal (setShowImportModal). The sync-models
|
||||
// endpoint is mocked to return instantly (0 models), so the modal reaches "done" phase
|
||||
// and shows a Close button. Dismiss it before interacting with the connection list.
|
||||
const importDialog = page.getByRole("dialog");
|
||||
// The Modal renders two "Close" elements (header X + footer button) — use .first()
|
||||
await expect(importDialog.getByRole("button", { name: "Close" }).first()).toBeVisible({ timeout: 15_000 });
|
||||
await importDialog.getByRole("button", { name: "Close" }).first().click();
|
||||
await expect(importDialog).not.toBeVisible();
|
||||
|
||||
await page.getByTitle(/^edit$/i).click();
|
||||
const editDialog = page.getByRole("dialog");
|
||||
await editDialog.getByLabel(/name/i).fill("Primary OpenAI Edited");
|
||||
await editDialog.getByLabel(/priority/i).fill("3");
|
||||
await editDialog.getByRole("button", { name: /^save$/i }).click();
|
||||
|
||||
await expect(page.getByText("Primary OpenAI Edited")).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => (await readProviderMockState(page)).connections[0]?.name)
|
||||
.toBe("Primary OpenAI Edited");
|
||||
|
||||
await page.getByRole("button", { name: /retest/i }).click();
|
||||
await expect.poll(async () => (await readProviderMockState(page)).retestCalls).toBe(1);
|
||||
|
||||
await page.evaluate(() => {
|
||||
(
|
||||
window as Window & { __providersTestState: { forceInvalidValidation: boolean } }
|
||||
).__providersTestState.forceInvalidValidation = true;
|
||||
});
|
||||
await page.getByRole("button", { name: /^add$/i }).first().click();
|
||||
const invalidDialog = page.getByRole("dialog");
|
||||
await invalidDialog.getByLabel(/name/i).fill("Broken OpenAI");
|
||||
await invalidDialog.getByLabel(/api key/i).fill("invalid-key");
|
||||
await invalidDialog.getByRole("button", { name: /^save$/i }).click();
|
||||
|
||||
await expect(invalidDialog.getByText(/api key validation failed/i)).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => (await readProviderMockState(page)).validationCalls)
|
||||
.toBeGreaterThan(1);
|
||||
await invalidDialog.getByRole("button", { name: /cancel/i }).click();
|
||||
await page.evaluate(() => {
|
||||
(
|
||||
window as Window & { __providersTestState: { forceInvalidValidation: boolean } }
|
||||
).__providersTestState.forceInvalidValidation = false;
|
||||
});
|
||||
|
||||
page.once("dialog", async (dialog) => {
|
||||
await dialog.accept();
|
||||
});
|
||||
await page.getByTitle(/^delete$/i).click();
|
||||
|
||||
await expect.poll(async () => (await readProviderMockState(page)).deleteCalls).toBe(1);
|
||||
await expect(page.getByText("Primary OpenAI Edited")).toHaveCount(0);
|
||||
await expect(page.getByText(/no connections yet/i)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
|
||||
|
||||
type ProxyStub = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
status: string;
|
||||
region?: string | null;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
test.describe("Proxy Registry smoke flow", () => {
|
||||
test("create, edit, bulk-assign modal, and delete proxy from settings advanced", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state: {
|
||||
proxies: ProxyStub[];
|
||||
nextId: number;
|
||||
bulkAssignCalls: number;
|
||||
} = {
|
||||
proxies: [],
|
||||
nextId: 1,
|
||||
bulkAssignCalls: 0,
|
||||
};
|
||||
|
||||
await page.route("**/api/settings/proxy?level=global", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ proxy: null }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/settings/proxies/health?hours=24", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
items: state.proxies.map((p) => ({
|
||||
proxyId: p.id,
|
||||
totalRequests: 0,
|
||||
successRate: null,
|
||||
avgLatencyMs: null,
|
||||
lastSeenAt: null,
|
||||
})),
|
||||
total: state.proxies.length,
|
||||
windowHours: 24,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/settings/proxies/bulk-assign", async (route) => {
|
||||
if (route.request().method() !== "PUT") {
|
||||
await route.fulfill({
|
||||
status: 405,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "method not allowed in test stub" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
state.bulkAssignCalls += 1;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
scope: "provider",
|
||||
requested: 2,
|
||||
updated: 2,
|
||||
failed: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/settings/proxies*", async (route) => {
|
||||
const req = route.request();
|
||||
const method = req.method();
|
||||
const url = new URL(req.url());
|
||||
const id = url.searchParams.get("id");
|
||||
const whereUsed = url.searchParams.get("whereUsed");
|
||||
|
||||
if (method === "GET" && id && whereUsed === "1") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ count: 0, assignments: [] }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: state.proxies, total: state.proxies.length }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST") {
|
||||
const payload = req.postDataJSON() as Partial<ProxyStub>;
|
||||
const proxy: ProxyStub = {
|
||||
id: `proxy-${state.nextId++}`,
|
||||
name: payload.name || "Proxy",
|
||||
type: payload.type || "http",
|
||||
host: payload.host || "localhost",
|
||||
port: Number(payload.port || 8080),
|
||||
status: payload.status || "active",
|
||||
region: payload.region || null,
|
||||
notes: payload.notes || null,
|
||||
};
|
||||
state.proxies.unshift(proxy);
|
||||
await route.fulfill({
|
||||
status: 201,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(proxy),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "PATCH") {
|
||||
const payload = req.postDataJSON() as Partial<ProxyStub> & { id?: string };
|
||||
const index = state.proxies.findIndex((p) => p.id === payload.id);
|
||||
if (index === -1) {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: { message: "Proxy not found", type: "not_found" } }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = {
|
||||
...state.proxies[index],
|
||||
...payload,
|
||||
} as ProxyStub;
|
||||
state.proxies[index] = updated;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(updated),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "DELETE") {
|
||||
if (!id) {
|
||||
await route.fulfill({
|
||||
status: 400,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: { message: "id is required", type: "invalid_request" } }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
state.proxies = state.proxies.filter((p) => p.id !== id);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ success: true }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 405,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "method not allowed in test stub" }),
|
||||
});
|
||||
});
|
||||
|
||||
// The proxy registry now lives under the "Proxy Pool" sub-tab of the proxy
|
||||
// settings page; navigate directly to it so the heading renders.
|
||||
await gotoDashboardRoute(page, "/dashboard/system/proxy?tab=proxy-pool");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Proxy Registry" })).toBeVisible();
|
||||
|
||||
await page.getByTestId("proxy-registry-open-create").click();
|
||||
const createDialog = page.getByRole("dialog");
|
||||
await expect(createDialog.getByText("Create Proxy")).toBeVisible();
|
||||
await createDialog.getByTestId("proxy-registry-name-input").fill("Registry Smoke Proxy");
|
||||
await createDialog.getByTestId("proxy-registry-host-input").fill("smoke.local");
|
||||
await createDialog.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
await expect(page.locator("table")).toContainText("Registry Smoke Proxy");
|
||||
await expect(page.locator("table")).toContainText("http://smoke.local:8080");
|
||||
|
||||
const row = page.locator("tr", { hasText: "Registry Smoke Proxy" });
|
||||
await row.getByRole("button", { name: "Edit" }).click();
|
||||
const editDialog = page.getByRole("dialog");
|
||||
await expect(editDialog.getByText("Edit Proxy")).toBeVisible();
|
||||
await editDialog.getByTestId("proxy-registry-host-input").fill("smoke-updated.local");
|
||||
await editDialog.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
await expect(page.locator("table")).toContainText("http://smoke-updated.local:8080");
|
||||
|
||||
await page.getByTestId("proxy-registry-open-bulk").click();
|
||||
const bulkDialog = page.getByRole("dialog");
|
||||
await expect(bulkDialog.getByText("Bulk Proxy Assignment")).toBeVisible();
|
||||
await bulkDialog.getByTestId("proxy-registry-bulk-scopeids-input").fill("openai,anthropic");
|
||||
await bulkDialog.getByTestId("proxy-registry-bulk-apply").click();
|
||||
|
||||
await expect
|
||||
.poll(() => state.bulkAssignCalls, {
|
||||
message: "Expected bulk assign API to be called exactly once",
|
||||
})
|
||||
.toBe(1);
|
||||
|
||||
await row.getByRole("button", { name: "Delete" }).click();
|
||||
await expect
|
||||
.poll(() => state.proxies.some((proxy) => proxy.name === "Registry Smoke Proxy"))
|
||||
.toBe(false);
|
||||
await expect(page.locator("tr", { hasText: "Registry Smoke Proxy" })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,382 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
|
||||
|
||||
const resilienceSettings = {
|
||||
requestQueue: {
|
||||
autoEnableApiKeyProviders: true,
|
||||
requestsPerMinute: 100,
|
||||
minTimeBetweenRequestsMs: 200,
|
||||
concurrentRequests: 10,
|
||||
maxWaitMs: 120000,
|
||||
},
|
||||
connectionCooldown: {
|
||||
oauth: {
|
||||
baseCooldownMs: 60000,
|
||||
useUpstreamRetryHints: false,
|
||||
maxBackoffSteps: 8,
|
||||
},
|
||||
apikey: {
|
||||
baseCooldownMs: 3000,
|
||||
useUpstreamRetryHints: true,
|
||||
maxBackoffSteps: 5,
|
||||
},
|
||||
},
|
||||
providerBreaker: {
|
||||
oauth: {
|
||||
failureThreshold: 3,
|
||||
degradationThreshold: 2,
|
||||
resetTimeoutMs: 60000,
|
||||
},
|
||||
apikey: {
|
||||
failureThreshold: 5,
|
||||
degradationThreshold: 3,
|
||||
resetTimeoutMs: 30000,
|
||||
},
|
||||
},
|
||||
waitForCooldown: {
|
||||
enabled: true,
|
||||
maxRetries: 3,
|
||||
maxRetryWaitSec: 30,
|
||||
},
|
||||
};
|
||||
|
||||
async function mockResilienceSettings(page: Page) {
|
||||
await page.route("**/api/resilience", async (route) => {
|
||||
const method = route.request().method();
|
||||
if (method === "GET") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(resilienceSettings),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
...resilienceSettings,
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function mockHealthPageApis(page: Page) {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await page.route("**/api/monitoring/health", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
status: "error",
|
||||
system: {
|
||||
uptime: 3723,
|
||||
version: "3.7.0",
|
||||
nodeVersion: "22.12.0",
|
||||
memoryUsage: {
|
||||
rss: 64 * 1024 * 1024,
|
||||
heapUsed: 24 * 1024 * 1024,
|
||||
heapTotal: 48 * 1024 * 1024,
|
||||
},
|
||||
},
|
||||
providerHealth: {
|
||||
openai: {
|
||||
state: "OPEN",
|
||||
failures: 3,
|
||||
retryAfterMs: 15000,
|
||||
lastFailure: now,
|
||||
},
|
||||
gemini: {
|
||||
state: "HALF_OPEN",
|
||||
failures: 1,
|
||||
retryAfterMs: 5000,
|
||||
lastFailure: now,
|
||||
},
|
||||
groq: {
|
||||
state: "CLOSED",
|
||||
failures: 0,
|
||||
retryAfterMs: 0,
|
||||
lastFailure: null,
|
||||
},
|
||||
},
|
||||
providerSummary: {
|
||||
configuredCount: 3,
|
||||
activeCount: 2,
|
||||
monitoredCount: 3,
|
||||
},
|
||||
rateLimitStatus: {},
|
||||
lockouts: {},
|
||||
sessions: {
|
||||
activeCount: 0,
|
||||
stickyBoundCount: 0,
|
||||
byApiKey: {},
|
||||
top: [],
|
||||
},
|
||||
quotaMonitor: {
|
||||
active: 0,
|
||||
alerting: 0,
|
||||
exhausted: 0,
|
||||
errors: 0,
|
||||
byProvider: {},
|
||||
monitors: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/telemetry/summary", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ p50: 120, p95: 240, p99: 450, totalRequests: 18 }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/cache/stats", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ size: 3, maxSize: 100, hitRate: 50, hits: 2, misses: 2 }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/rate-limits", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
cacheStats: {
|
||||
defaultCount: 0,
|
||||
tool: { entries: 0, patterns: 0 },
|
||||
family: { entries: 0, patterns: 0 },
|
||||
session: { entries: 0, patterns: 0 },
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/health/degradation", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
summary: { full: 0, reduced: 0, minimal: 0, default: 0 },
|
||||
features: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/db/health", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
isHealthy: true,
|
||||
issues: [],
|
||||
repairedCount: 0,
|
||||
backupCreated: false,
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function mockProvidersPageApis(page: Page) {
|
||||
await page.route("**/api/providers", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
connections: [
|
||||
{
|
||||
id: "conn-openai-main",
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
name: "OpenAI Main",
|
||||
testStatus: "active",
|
||||
},
|
||||
{
|
||||
id: "conn-gemini-main",
|
||||
provider: "gemini",
|
||||
authType: "apikey",
|
||||
name: "Gemini Main",
|
||||
testStatus: "active",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/provider-nodes", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ nodes: [], ccCompatibleProviderEnabled: false }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/providers/expiration", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/system/env/repair", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ available: false, missingCount: 0 }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function mockIntelligentCombosPageApis(page: Page) {
|
||||
await page.route("**/api/combos", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
combos: [
|
||||
{
|
||||
id: "combo-auto",
|
||||
name: "combo-auto",
|
||||
models: ["openai/gpt-4o-mini", "gemini/gemini-2.5-pro"],
|
||||
strategy: "auto",
|
||||
config: {
|
||||
candidatePool: ["openai", "gemini", "anthropic"],
|
||||
modePack: "ship-fast",
|
||||
explorationRate: 0.15,
|
||||
},
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/combos/metrics", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ metrics: {} }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/providers", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
connections: [
|
||||
{ id: "conn-openai", provider: "openai", name: "OpenAI", testStatus: "active" },
|
||||
{ id: "conn-gemini", provider: "gemini", name: "Gemini", testStatus: "active" },
|
||||
{
|
||||
id: "conn-anthropic",
|
||||
provider: "anthropic",
|
||||
name: "Anthropic",
|
||||
testStatus: "active",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/provider-nodes", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ nodes: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/settings/proxy", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ combos: {} }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Resilience Plan Alignment", () => {
|
||||
test("resilience settings page only shows the plan-aligned cooldown fields", async ({ page }) => {
|
||||
await mockResilienceSettings(page);
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/settings?tab=resilience");
|
||||
const resiliencePanel = page.getByRole("tabpanel", { name: "Resilience" });
|
||||
await expect(
|
||||
resiliencePanel.getByRole("heading", { name: "Connection Cooldown", exact: true })
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
await expect(resiliencePanel.getByText(/Base cooldown\s*60000ms/)).toBeVisible();
|
||||
await expect(resiliencePanel.getByText(/Use upstream retry hints\s*No/)).toBeVisible();
|
||||
await expect(resiliencePanel.getByText(/Max backoff steps\s*8/)).toBeVisible();
|
||||
await expect(resiliencePanel.getByText(/Rate-limit fallback/i)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("health page renders provider breaker runtime state for multiple providers", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockHealthPageApis(page);
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/health");
|
||||
const providerHealthRegion = page.getByRole("region", { name: "Provider health status" });
|
||||
await expect(providerHealthRegion).toBeVisible({ timeout: 15000 });
|
||||
await expect(providerHealthRegion.getByText("OpenAI")).toBeVisible();
|
||||
await expect(providerHealthRegion.getByText("Gemini")).toBeVisible();
|
||||
await expect(providerHealthRegion.getByText("Groq")).toBeVisible();
|
||||
await expect(
|
||||
providerHealthRegion.getByText("Recovering", { exact: true }).first()
|
||||
).toBeVisible();
|
||||
await expect(providerHealthRegion.getByText("Down", { exact: true }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("providers page no longer requests legacy model availability data", async ({ page }) => {
|
||||
let availabilityRequests = 0;
|
||||
|
||||
await mockProvidersPageApis(page);
|
||||
await page.route("**/api/models/availability", async (route) => {
|
||||
availabilityRequests += 1;
|
||||
await route.fulfill({
|
||||
status: 410,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "removed" }),
|
||||
});
|
||||
});
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/providers");
|
||||
await expect(page.getByText("OpenAI").first()).toBeVisible({ timeout: 15000 });
|
||||
|
||||
expect(availabilityRequests).toBe(0);
|
||||
await expect(page.getByText(/Model Availability/i)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("intelligent combo panel stays config-only and does not fetch breaker runtime state", async ({
|
||||
page,
|
||||
}) => {
|
||||
let monitoringHealthRequests = 0;
|
||||
|
||||
await mockIntelligentCombosPageApis(page);
|
||||
await page.route("**/api/monitoring/health", async (route) => {
|
||||
monitoringHealthRequests += 1;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ providerHealth: {} }),
|
||||
});
|
||||
});
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/combos?filter=intelligent");
|
||||
await expect(page.getByText("Intelligent Routing Dashboard")).toBeVisible({ timeout: 15000 });
|
||||
const healthRequestsAfterPanelVisible = monitoringHealthRequests;
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
expect(monitoringHealthRequests).toBe(healthRequestsAfterPanelVisible);
|
||||
await expect(page.getByText("Routing Inputs", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/Excluded Providers/i)).toHaveCount(0);
|
||||
await expect(page.getByText(/Incident Mode/i)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
import { A11Y_CHECKS, generateTestMatrix } from "./responsiveSpecs";
|
||||
|
||||
const executableChecks = A11Y_CHECKS.filter((check) => check.kind === "evaluate");
|
||||
|
||||
test.describe("Responsive matrix", () => {
|
||||
for (const { viewport, page: pageSpec, testName } of generateTestMatrix()) {
|
||||
test(`${testName} has no basic responsive regressions`, async ({ page }) => {
|
||||
test.skip(pageSpec.requiresAuth, "Requires authenticated session before responsive checks.");
|
||||
|
||||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||
await page.goto(pageSpec.path);
|
||||
|
||||
for (const check of executableChecks) {
|
||||
const passed = await page.evaluate(check.evaluate);
|
||||
expect(passed, check.criteria).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Responsive Test Specs — T-39
|
||||
*
|
||||
* Test specifications for Playwright responsive testing.
|
||||
* These define the viewports and pages to test.
|
||||
*
|
||||
* Usage with Playwright:
|
||||
* import { VIEWPORTS, PAGES, generateTestMatrix } from "./responsiveSpecs";
|
||||
*
|
||||
* @module tests/e2e/responsiveSpecs
|
||||
*/
|
||||
|
||||
/**
|
||||
* Viewport definitions for responsive testing.
|
||||
*/
|
||||
export const VIEWPORTS = {
|
||||
mobile: { width: 375, height: 812, label: "Mobile (375px)" },
|
||||
tablet: { width: 768, height: 1024, label: "Tablet (768px)" },
|
||||
desktop: { width: 1280, height: 800, label: "Desktop (1280px)" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Pages to test with responsive viewports.
|
||||
*/
|
||||
export const PAGES = [
|
||||
{ path: "/login", name: "Login", requiresAuth: false },
|
||||
{ path: "/dashboard", name: "Dashboard", requiresAuth: true },
|
||||
{ path: "/dashboard/providers", name: "Providers", requiresAuth: true },
|
||||
{ path: "/dashboard/settings", name: "Settings", requiresAuth: true },
|
||||
];
|
||||
|
||||
/**
|
||||
* Accessibility checks to perform on each page.
|
||||
*/
|
||||
export const A11Y_CHECKS = [
|
||||
{
|
||||
id: "overflow-x",
|
||||
kind: "evaluate",
|
||||
evaluate: () => document.body.scrollWidth <= document.documentElement.clientWidth,
|
||||
criteria: "No horizontal overflow (scrollWidth <= clientWidth)",
|
||||
description: "No horizontal overflow",
|
||||
},
|
||||
{
|
||||
id: "touch-targets",
|
||||
kind: "manual",
|
||||
criteria: "Minimum 44px touch targets on mobile",
|
||||
description: "Touch targets ≥ 44px",
|
||||
},
|
||||
{
|
||||
id: "font-size",
|
||||
kind: "manual",
|
||||
criteria: "Minimum 16px base font on mobile",
|
||||
description: "Base font ≥ 16px",
|
||||
},
|
||||
{
|
||||
id: "viewport-meta",
|
||||
kind: "manual",
|
||||
criteria: "Viewport meta tag is present",
|
||||
description: "Viewport meta present",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Generate test matrix (viewport × page combinations).
|
||||
*
|
||||
* @returns {Array<{ viewport: typeof VIEWPORTS.mobile, page: typeof PAGES[0], testName: string }>}
|
||||
*/
|
||||
export function generateTestMatrix() {
|
||||
const matrix = [];
|
||||
|
||||
for (const [vpKey, viewport] of Object.entries(VIEWPORTS)) {
|
||||
for (const page of PAGES) {
|
||||
matrix.push({
|
||||
viewport,
|
||||
page,
|
||||
testName: `${page.name} @ ${viewport.label}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return matrix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get viewport names.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function getViewportNames() {
|
||||
return Object.keys(VIEWPORTS);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
|
||||
|
||||
test.describe("Search Tools Studio", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Mock the search providers catalog API
|
||||
await page.route("**/api/search/providers", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
providers: [
|
||||
{
|
||||
id: "serper",
|
||||
name: "Serper",
|
||||
kind: "search",
|
||||
costPerQuery: 0.001,
|
||||
freeMonthlyQuota: 100,
|
||||
searchTypes: ["web", "news"],
|
||||
status: "configured",
|
||||
configureHref: "/dashboard/providers",
|
||||
},
|
||||
{
|
||||
id: "firecrawl",
|
||||
name: "Firecrawl",
|
||||
kind: "fetch",
|
||||
costPerQuery: 0.002,
|
||||
freeMonthlyQuota: 0,
|
||||
fetchFormats: ["markdown", "html", "links"],
|
||||
status: "configured",
|
||||
configureHref: "/dashboard/providers",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Mock the search endpoint
|
||||
await page.route("**/api/v1/search", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
provider: "serper",
|
||||
results: [
|
||||
{
|
||||
title: "Test Result",
|
||||
url: "https://example.com",
|
||||
snippet: "A test search result",
|
||||
score: 0.9,
|
||||
},
|
||||
],
|
||||
cost: 0.001,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Mock the web fetch endpoint
|
||||
await page.route("**/api/v1/web/fetch", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
provider: "firecrawl",
|
||||
url: "https://example.com",
|
||||
content: "# Example Page\n\nThis is a test page.",
|
||||
links: ["https://example.com/about"],
|
||||
metadata: { title: "Example", description: "Test" },
|
||||
screenshot_url: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("loads page and shows 3 tabs", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/search-tools");
|
||||
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
|
||||
// The Studio should render 3 tabs in a tablist
|
||||
const tablist = page.getByRole("tablist").first();
|
||||
await expect(tablist).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Check all 3 tabs are present
|
||||
const searchTab = page.getByRole("tab", { name: /search/i }).first();
|
||||
const scrapeTab = page.getByRole("tab", { name: /scrape/i });
|
||||
const compareTab = page.getByRole("tab", { name: /compare/i });
|
||||
|
||||
await expect(searchTab).toBeVisible({ timeout: 15000 });
|
||||
await expect(scrapeTab).toBeVisible({ timeout: 15000 });
|
||||
await expect(compareTab).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
test("shows SearchConceptCard (modalities guide)", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/search-tools");
|
||||
|
||||
// The concept card should be visible and contain a modalities guide
|
||||
// It may be rendered as a collapsible section
|
||||
const conceptCard = page.locator("[data-testid='search-concept-card'], .search-concept-card").first();
|
||||
|
||||
// Alternative: look for the guide text since it's always visible
|
||||
// The card has a "Modalities guide" or similar label
|
||||
const guideText = page
|
||||
.getByText(/modalities guide|guia de modalidades/i)
|
||||
.first();
|
||||
await expect(guideText).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
test("switches to Scrape tab and shows URL input", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/search-tools");
|
||||
|
||||
// Wait for tabs
|
||||
const scrapeTab = page.getByRole("tab", { name: /scrape/i });
|
||||
await expect(scrapeTab).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Click Scrape tab
|
||||
await scrapeTab.click();
|
||||
|
||||
// The Scrape tab should have a URL input
|
||||
const urlInput = page.locator('input[type="url"], input[placeholder*="http"], input[placeholder*="URL"], input[placeholder*="url"]').first();
|
||||
await expect(urlInput).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("Search tab is active by default", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/search-tools");
|
||||
|
||||
const searchTab = page.getByRole("tab", { name: /search/i }).first();
|
||||
await expect(searchTab).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Search tab should be selected by default
|
||||
await expect(searchTab).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
|
||||
|
||||
test.describe("Settings Toggles", () => {
|
||||
const waitForSettingsShell = async (page) => {
|
||||
await expect(page.getByRole("tab", { name: /general/i }).first()).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
};
|
||||
|
||||
const getDebugToggle = (page) =>
|
||||
page
|
||||
.getByText(/enable debug mode/i)
|
||||
.locator('xpath=ancestor::div[contains(@class, "flex items-center justify-between")][1]')
|
||||
.getByRole("switch");
|
||||
|
||||
const getSidebarVisibilityToggle = (page, itemLabel: string) =>
|
||||
page
|
||||
.getByRole("tabpanel", { name: /appearance/i })
|
||||
.getByText(new RegExp(`^${itemLabel}$`, "i"))
|
||||
.locator('xpath=ancestor::div[contains(@class, "flex items-center justify-between")][1]')
|
||||
.getByRole("switch");
|
||||
|
||||
const waitForSettingsPatch = (page) =>
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes("/api/settings") &&
|
||||
response.request().method() === "PATCH" &&
|
||||
response.ok()
|
||||
);
|
||||
|
||||
test("Debug mode toggle should work", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/settings");
|
||||
await waitForSettingsShell(page);
|
||||
await page.getByRole("tab", { name: /general/i }).click();
|
||||
|
||||
const debugToggle = getDebugToggle(page);
|
||||
|
||||
await expect(debugToggle).toBeVisible({ timeout: 15000 });
|
||||
await expect(debugToggle).toBeEnabled({ timeout: 15000 });
|
||||
|
||||
const initialState = await debugToggle.getAttribute("aria-checked");
|
||||
await Promise.all([waitForSettingsPatch(page), debugToggle.click()]);
|
||||
await expect(debugToggle).toHaveAttribute(
|
||||
"aria-checked",
|
||||
initialState === "true" ? "false" : "true",
|
||||
{ timeout: 15000 }
|
||||
);
|
||||
});
|
||||
|
||||
test("Sidebar visibility toggle should work", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/settings");
|
||||
await waitForSettingsShell(page);
|
||||
await page.getByRole("tab", { name: /appearance/i }).click();
|
||||
|
||||
const sidebarToggle = getSidebarVisibilityToggle(page, "Health");
|
||||
|
||||
await expect(sidebarToggle).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const initialState = await sidebarToggle.getAttribute("aria-checked");
|
||||
await Promise.all([waitForSettingsPatch(page), sidebarToggle.click()]);
|
||||
await expect(sidebarToggle).toHaveAttribute(
|
||||
"aria-checked",
|
||||
initialState === "true" ? "false" : "true",
|
||||
{ timeout: 15000 }
|
||||
);
|
||||
});
|
||||
|
||||
test("Clear Cache button calls DELETE /api/cache", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/settings");
|
||||
await waitForSettingsShell(page);
|
||||
await page.getByRole("tab", { name: /general/i }).click();
|
||||
|
||||
const clearBtn = page.getByRole("button", { name: /clear cache/i });
|
||||
await expect(clearBtn).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const [request] = await Promise.all([
|
||||
page.waitForRequest((req) => req.url().includes("/api/cache") && req.method() === "DELETE"),
|
||||
clearBtn.click(),
|
||||
]);
|
||||
expect(request).toBeTruthy();
|
||||
});
|
||||
|
||||
test("Purge Expired Logs button calls POST /api/settings/purge-logs", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/settings");
|
||||
await waitForSettingsShell(page);
|
||||
await page.getByRole("tab", { name: /general/i }).click();
|
||||
|
||||
const purgeBtn = page.getByRole("button", { name: /purge expired logs/i });
|
||||
await expect(purgeBtn).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const [request] = await Promise.all([
|
||||
page.waitForRequest(
|
||||
(req) => req.url().includes("/api/settings/purge-logs") && req.method() === "POST"
|
||||
),
|
||||
purgeBtn.click(),
|
||||
]);
|
||||
expect(request).toBeTruthy();
|
||||
});
|
||||
|
||||
test("Debug mode should persist after page reload", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/settings");
|
||||
await waitForSettingsShell(page);
|
||||
await page.getByRole("tab", { name: /general/i }).click();
|
||||
|
||||
const debugToggle = getDebugToggle(page);
|
||||
|
||||
await expect(debugToggle).toBeVisible({ timeout: 15000 });
|
||||
await expect(debugToggle).toBeEnabled({ timeout: 15000 });
|
||||
|
||||
const initialState = await debugToggle.getAttribute("aria-checked");
|
||||
await Promise.all([waitForSettingsPatch(page), debugToggle.click()]);
|
||||
const nextState = initialState === "true" ? "false" : "true";
|
||||
await expect(debugToggle).toHaveAttribute("aria-checked", nextState, { timeout: 15000 });
|
||||
await page.reload();
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
|
||||
await waitForSettingsShell(page);
|
||||
await page.getByRole("tab", { name: /general/i }).click();
|
||||
|
||||
const reloadedToggle = getDebugToggle(page);
|
||||
await expect(reloadedToggle).toBeEnabled({ timeout: 15000 });
|
||||
await expect(reloadedToggle).toHaveAttribute("aria-checked", nextState, {
|
||||
timeout: 15000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
|
||||
|
||||
const NAVIGATION_TIMEOUT_MS = 300_000;
|
||||
|
||||
type SkillRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type MarketplaceSkill = {
|
||||
name: string;
|
||||
description: string;
|
||||
version: string;
|
||||
sourceUrl: string;
|
||||
skillMdContent: string;
|
||||
};
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Skills marketplace", () => {
|
||||
test.setTimeout(600_000);
|
||||
|
||||
test("searches, installs, toggles, and scrolls through skills in the dashboard", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state: {
|
||||
skills: SkillRecord[];
|
||||
marketplace: MarketplaceSkill[];
|
||||
nextId: number;
|
||||
toggleCalls: number;
|
||||
marketplaceInstalls: number;
|
||||
customInstalls: number;
|
||||
} = {
|
||||
skills: [
|
||||
{
|
||||
id: "skill-weather",
|
||||
name: "lookupWeather",
|
||||
version: "1.0.0",
|
||||
description: "Returns current weather conditions.",
|
||||
enabled: false,
|
||||
createdAt: new Date("2026-04-05T20:00:00.000Z").toISOString(),
|
||||
},
|
||||
],
|
||||
marketplace: Array.from({ length: 12 }, (_, index) => ({
|
||||
name: index === 0 ? "Weather Pro" : `Skill Page ${index + 1}`,
|
||||
description:
|
||||
index === 0
|
||||
? "Extended weather reports with severe alert support."
|
||||
: `Marketplace skill result ${index + 1}.`,
|
||||
version: `1.${index}.0`,
|
||||
sourceUrl: `https://skillsmp.example/${index + 1}`,
|
||||
skillMdContent: `# Skill ${index + 1}\n\nMarketplace content`,
|
||||
})),
|
||||
nextId: 2,
|
||||
toggleCalls: 0,
|
||||
marketplaceInstalls: 0,
|
||||
customInstalls: 0,
|
||||
};
|
||||
|
||||
await page.route(/\/api\/skills\/executions(?:\?.*)?$/, async (route) => {
|
||||
await fulfillJson(route, { data: [], total: 0, totalPages: 1 });
|
||||
});
|
||||
|
||||
await page.route(/\/api\/skills\/marketplace(?:\?.*)?$/, async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const query = url.searchParams.get("q")?.toLowerCase() || "";
|
||||
const results = state.marketplace.filter((skill) =>
|
||||
query
|
||||
? skill.name.toLowerCase().includes(query) ||
|
||||
skill.description.toLowerCase().includes(query)
|
||||
: true
|
||||
);
|
||||
await fulfillJson(route, { skills: results });
|
||||
});
|
||||
|
||||
await page.route("**/api/skills/marketplace/install", async (route) => {
|
||||
state.marketplaceInstalls += 1;
|
||||
const payload = (route.request().postDataJSON() as Partial<MarketplaceSkill>) || {};
|
||||
state.skills.push({
|
||||
id: `skill-${state.nextId++}`,
|
||||
name: payload.name || "marketplace-skill",
|
||||
version: payload.version || "1.0.0",
|
||||
description: payload.description || "Installed from marketplace",
|
||||
enabled: true,
|
||||
createdAt: new Date("2026-04-05T20:10:00.000Z").toISOString(),
|
||||
});
|
||||
await fulfillJson(route, { success: true });
|
||||
});
|
||||
|
||||
await page.route("**/api/skills/install", async (route) => {
|
||||
state.customInstalls += 1;
|
||||
const payload = (route.request().postDataJSON() as Partial<SkillRecord>) || {};
|
||||
state.skills.push({
|
||||
id: `skill-${state.nextId++}`,
|
||||
name: payload.name || "custom-skill",
|
||||
version: payload.version || "1.0.0",
|
||||
description: payload.description || "Custom installed skill",
|
||||
enabled: true,
|
||||
createdAt: new Date("2026-04-05T20:20:00.000Z").toISOString(),
|
||||
});
|
||||
await fulfillJson(route, {
|
||||
success: true,
|
||||
id: `skill-${state.nextId}`,
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(/\/api\/skills\/skill-[^/?]+(?:\?.*)?$/, async (route) => {
|
||||
if (route.request().method() !== "PUT") {
|
||||
await fulfillJson(route, { error: "Method not allowed in skill detail stub" }, 405);
|
||||
return;
|
||||
}
|
||||
|
||||
state.toggleCalls += 1;
|
||||
const skillId = route.request().url().split("/").pop() || "";
|
||||
state.skills = state.skills.map((skill) =>
|
||||
skill.id === skillId ? { ...skill, enabled: !skill.enabled } : skill
|
||||
);
|
||||
await fulfillJson(route, { success: true });
|
||||
});
|
||||
|
||||
await page.route(/\/api\/skills(?:\?.*)?$/, async (route) => {
|
||||
await fulfillJson(route, {
|
||||
data: state.skills,
|
||||
total: state.skills.length,
|
||||
totalPages: 1,
|
||||
});
|
||||
});
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/omni-skills", {
|
||||
timeoutMs: NAVIGATION_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
await expect(page.getByText("lookupWeather")).toBeVisible({ timeout: 15000 });
|
||||
const weatherCard = page
|
||||
.locator("div")
|
||||
.filter({ has: page.getByText("lookupWeather") })
|
||||
.first();
|
||||
const weatherSwitch = weatherCard.getByRole("switch");
|
||||
await expect(weatherSwitch).toHaveAttribute("aria-checked", "false");
|
||||
await weatherSwitch.click();
|
||||
await expect(weatherSwitch).toHaveAttribute("aria-checked", "true");
|
||||
await expect.poll(() => state.toggleCalls).toBe(1);
|
||||
|
||||
await page.getByRole("button", { name: /marketplace/i }).click();
|
||||
const marketplaceSearch = page.getByPlaceholder(/Search SkillsMP\.\.\./i);
|
||||
await expect(marketplaceSearch).toBeVisible({ timeout: 15000 });
|
||||
|
||||
await marketplaceSearch.fill("weather");
|
||||
await page.getByRole("button", { name: /search skillsmp/i }).click();
|
||||
await expect(page.getByText("Weather Pro")).toBeVisible({ timeout: 15000 });
|
||||
await page.getByRole("button", { name: /^install$/i }).click();
|
||||
await expect.poll(() => state.marketplaceInstalls).toBe(1);
|
||||
|
||||
await marketplaceSearch.fill("");
|
||||
await page.getByRole("button", { name: /search skillsmp/i }).click();
|
||||
const lastMarketplaceSkill = page.getByText("Skill Page 12").last();
|
||||
await lastMarketplaceSkill.scrollIntoViewIfNeeded();
|
||||
await expect(lastMarketplaceSkill).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: /^skills$/i }).click();
|
||||
await expect(page.getByText("Weather Pro")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: /^install skill$/i }).click();
|
||||
const installDialog = page
|
||||
.locator("div.fixed.inset-0.z-50")
|
||||
.filter({ has: page.getByRole("heading", { name: /^install skill$/i }) });
|
||||
await expect(installDialog).toBeVisible();
|
||||
await installDialog.locator("textarea").fill(
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "customMath",
|
||||
version: "1.0.0",
|
||||
description: "Custom calculator skill",
|
||||
schema: { input: {}, output: {} },
|
||||
handlerCode: "export default async () => ({ ok: true });",
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
await installDialog.getByRole("button", { name: /^install$/i }).click();
|
||||
|
||||
await expect.poll(() => state.customInstalls).toBe(1);
|
||||
await expect(installDialog.getByText(/skill installed/i)).toBeVisible();
|
||||
await installDialog.getByRole("button", { name: /cancel/i }).click();
|
||||
|
||||
await expect(page.getByText("customMath")).toBeVisible();
|
||||
|
||||
const installedSwitch = page
|
||||
.getByRole("heading", { name: "Weather Pro" })
|
||||
.locator('xpath=ancestor::div[contains(@class, "flex items-center justify-between")][1]')
|
||||
.getByRole("switch");
|
||||
await expect(installedSwitch).toHaveAttribute("aria-checked", "true");
|
||||
await installedSwitch.click();
|
||||
await expect(installedSwitch).toHaveAttribute("aria-checked", "false");
|
||||
await expect.poll(() => state.toggleCalls).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Smoke — Static Pages", () => {
|
||||
test("landing page renders", async ({ page }) => {
|
||||
await page.goto("/landing");
|
||||
await expect(page).toHaveTitle(/OmniRoute/i);
|
||||
const hero = page.locator("h1").first();
|
||||
await expect(hero).toBeVisible();
|
||||
});
|
||||
|
||||
test("/terms page renders all sections", async ({ page }) => {
|
||||
await page.goto("/terms");
|
||||
await expect(page).toHaveTitle(/Terms of Service/i);
|
||||
await expect(page.locator("h1")).toContainText("Terms of Service");
|
||||
// Verify at least 4 section headings
|
||||
const headings = page.locator("h2");
|
||||
await expect(headings).toHaveCount(6);
|
||||
});
|
||||
|
||||
test("/privacy page renders all sections", async ({ page }) => {
|
||||
await page.goto("/privacy");
|
||||
await expect(page).toHaveTitle(/Privacy Policy/i);
|
||||
await expect(page.locator("h1")).toContainText("Privacy Policy");
|
||||
const headings = page.locator("h2");
|
||||
await expect(headings).toHaveCount(7);
|
||||
});
|
||||
|
||||
test("back link on /terms navigates home", async ({ page }) => {
|
||||
await page.goto("/terms");
|
||||
const backLink = page.locator('a[href="/"]').first();
|
||||
await expect(backLink).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,699 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import net from "node:net";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { MockUpstreamServer, buildCompletion, buildError } from "./helpers/mockUpstreamServer.ts";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-system-failover-"));
|
||||
const DASHBOARD_PORT = await getFreePort();
|
||||
const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
|
||||
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "system-failover-secret-123456";
|
||||
process.env.REQUIRE_API_KEY = "false";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const combosDb = await import("../../src/lib/db/combos.ts");
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
const accountFallback = await import("../../open-sse/services/accountFallback.ts");
|
||||
|
||||
function resetConnectionCooldowns() {
|
||||
accountFallback.clearAllModelLockouts();
|
||||
const db = core.getDbInstance() as any;
|
||||
db.prepare(
|
||||
`UPDATE provider_connections
|
||||
SET rate_limited_until = NULL,
|
||||
test_status = 'active',
|
||||
backoff_level = 0,
|
||||
last_error = NULL,
|
||||
last_error_type = NULL,
|
||||
last_error_source = NULL,
|
||||
error_code = NULL,
|
||||
last_error_at = NULL
|
||||
WHERE rate_limited_until IS NOT NULL
|
||||
OR test_status != 'active'`
|
||||
).run();
|
||||
db.pragma("wal_checkpoint(TRUNCATE)");
|
||||
}
|
||||
|
||||
function getFreePort() {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close();
|
||||
reject(new Error("Failed to allocate a free port"));
|
||||
return;
|
||||
}
|
||||
const { port } = address;
|
||||
server.close((closeError) => {
|
||||
if (closeError) reject(closeError);
|
||||
else resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function seedProvider(label: string, apiKey: string, baseUrl: string) {
|
||||
const providerId = `openai-compatible-sys-${label}`;
|
||||
await providersDb.createProviderNode({
|
||||
id: providerId,
|
||||
type: "openai-compatible",
|
||||
name: `System ${label}`,
|
||||
prefix: label,
|
||||
apiType: "chat",
|
||||
baseUrl,
|
||||
});
|
||||
await providersDb.createProviderConnection({
|
||||
provider: providerId,
|
||||
authType: "apikey",
|
||||
name: `conn-${label}`,
|
||||
apiKey,
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
providerSpecificData: { baseUrl, apiType: "chat" },
|
||||
});
|
||||
return { providerId, model: `${label}/test-model`, apiKey };
|
||||
}
|
||||
|
||||
function createServerProcess(dataDir: string, port: number) {
|
||||
const stdoutLines: string[] = [];
|
||||
const stderrLines: string[] = [];
|
||||
let exitInfo: { code: number | null; signal: NodeJS.Signals | null } | null = null;
|
||||
const child = spawn(process.execPath, ["scripts/dev/run-next-playwright.mjs", "dev"], {
|
||||
cwd: REPO_ROOT,
|
||||
env: {
|
||||
...process.env,
|
||||
DATA_DIR: dataDir,
|
||||
PORT: String(port),
|
||||
DASHBOARD_PORT: String(port),
|
||||
API_PORT: String(port),
|
||||
HOST: "127.0.0.1",
|
||||
REQUIRE_API_KEY: "false",
|
||||
API_KEY_SECRET: process.env.API_KEY_SECRET || "system-failover-secret-123456",
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true",
|
||||
INITIAL_PASSWORD: "",
|
||||
NEXT_TELEMETRY_DISABLED: "1",
|
||||
OMNIROUTE_DISABLE_BACKGROUND_SERVICES: "true",
|
||||
OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK: "true",
|
||||
OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK: "true",
|
||||
OMNIROUTE_HIDE_HEALTHCHECK_LOGS: "true",
|
||||
OMNIROUTE_E2E_BOOTSTRAP_MODE: "open",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
child.once("exit", (code, signal) => {
|
||||
exitInfo = { code, signal };
|
||||
});
|
||||
child.stdout.on("data", (chunk) => {
|
||||
const lines = String(chunk).split(/\r?\n/).filter(Boolean);
|
||||
stdoutLines.push(...lines);
|
||||
if (stdoutLines.length > 200) stdoutLines.splice(0, stdoutLines.length - 200);
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
const lines = String(chunk).split(/\r?\n/).filter(Boolean);
|
||||
stderrLines.push(...lines);
|
||||
if (stderrLines.length > 200) stderrLines.splice(0, stderrLines.length - 200);
|
||||
});
|
||||
|
||||
return {
|
||||
child,
|
||||
stdoutLines,
|
||||
stderrLines,
|
||||
baseUrl: `http://127.0.0.1:${port}`,
|
||||
get exitInfo() {
|
||||
return exitInfo;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForServer(
|
||||
baseUrl: string,
|
||||
logs: {
|
||||
stdoutLines: string[];
|
||||
stderrLines: string[];
|
||||
exitInfo?: { code: number | null; signal: NodeJS.Signals | null } | null;
|
||||
}
|
||||
) {
|
||||
const startedAt = Date.now();
|
||||
let lastError = "";
|
||||
while (Date.now() - startedAt < 120_000) {
|
||||
if (logs.exitInfo) {
|
||||
throw new Error(
|
||||
[
|
||||
`OmniRoute exited before it became ready (code=${logs.exitInfo.code}, signal=${logs.exitInfo.signal})`,
|
||||
"--- stdout ---",
|
||||
...logs.stdoutLines.slice(-40),
|
||||
"--- stderr ---",
|
||||
...logs.stderrLines.slice(-40),
|
||||
].join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/monitoring/health`, {
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
if (response.ok) return;
|
||||
lastError = `HTTP ${response.status}`;
|
||||
} catch (error: any) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
[
|
||||
`Timed out waiting for OmniRoute to start: ${lastError}`,
|
||||
"--- stdout ---",
|
||||
...logs.stdoutLines.slice(-40),
|
||||
"--- stderr ---",
|
||||
...logs.stderrLines.slice(-40),
|
||||
].join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
async function stopProcess(child: ReturnType<typeof spawn>) {
|
||||
if (child.killed) return;
|
||||
child.kill("SIGTERM");
|
||||
const exited = await Promise.race([
|
||||
new Promise<boolean>((resolve) => child.once("exit", () => resolve(true))),
|
||||
sleep(5_000).then(() => false),
|
||||
]);
|
||||
if (!exited && !child.killed) {
|
||||
child.kill("SIGKILL");
|
||||
await new Promise<void>((resolve) => child.once("exit", () => resolve()));
|
||||
}
|
||||
}
|
||||
|
||||
async function postChat(
|
||||
baseUrl: string,
|
||||
model: string,
|
||||
content: string,
|
||||
extraHeaders?: Record<string, string>
|
||||
) {
|
||||
const response = await fetch(`${baseUrl}/api/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...extraHeaders },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
stream: false,
|
||||
messages: [{ role: "user", content }],
|
||||
}),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
const text = await response.text();
|
||||
const json = text ? JSON.parse(text) : {};
|
||||
return { response, json };
|
||||
}
|
||||
|
||||
async function resetBreakers(url: string) {
|
||||
await fetch(`${url}/api/resilience/reset`, {
|
||||
method: "POST",
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
}
|
||||
|
||||
const serverA = new MockUpstreamServer();
|
||||
const serverB = new MockUpstreamServer();
|
||||
let app:
|
||||
| {
|
||||
child: ReturnType<typeof spawn>;
|
||||
stdoutLines: string[];
|
||||
stderrLines: string[];
|
||||
baseUrl: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const TOKEN_A = "sk-sys-a";
|
||||
const TOKEN_B = "sk-sys-b";
|
||||
const TOKEN_A2 = "sk-sys-a2";
|
||||
const TOKEN_B2 = "sk-sys-b2";
|
||||
|
||||
test.before(async () => {
|
||||
const baseUrlA = await serverA.start();
|
||||
const baseUrlB = await serverB.start();
|
||||
|
||||
serverA.configureToken(TOKEN_A, {
|
||||
defaultResponse: buildCompletion("server A ok", { model: "sys-a/test-model" }),
|
||||
});
|
||||
serverA.configureToken(TOKEN_A2, {
|
||||
defaultResponse: buildCompletion("server A2 ok", { model: "sys-a2/test-model" }),
|
||||
});
|
||||
serverB.configureToken(TOKEN_B, {
|
||||
defaultResponse: buildCompletion("server B ok", { model: "sys-b/test-model" }),
|
||||
});
|
||||
serverB.configureToken(TOKEN_B2, {
|
||||
defaultResponse: buildCompletion("server B2 ok", { model: "sys-b2/test-model" }),
|
||||
});
|
||||
|
||||
const provA = await seedProvider("sys-a", TOKEN_A, baseUrlA);
|
||||
const provB = await seedProvider("sys-b", TOKEN_B, baseUrlB);
|
||||
const provA2 = await seedProvider("sys-a2", TOKEN_A2, baseUrlA);
|
||||
const provB2 = await seedProvider("sys-b2", TOKEN_B2, baseUrlB);
|
||||
|
||||
await combosDb.createCombo({
|
||||
name: "sys-priority",
|
||||
strategy: "priority",
|
||||
config: { maxRetries: 0, retryDelayMs: 0 },
|
||||
models: [provA.model, provB.model],
|
||||
});
|
||||
await combosDb.createCombo({
|
||||
name: "sys-priority-v2",
|
||||
strategy: "priority",
|
||||
config: { maxRetries: 0, retryDelayMs: 0 },
|
||||
models: [provA2.model, provB2.model],
|
||||
});
|
||||
await combosDb.createCombo({
|
||||
name: "sys-priority-fobr",
|
||||
strategy: "priority",
|
||||
config: { maxRetries: 0, retryDelayMs: 0, failoverBeforeRetry: true },
|
||||
models: [provA.model, provB.model],
|
||||
});
|
||||
await combosDb.createCombo({
|
||||
name: "sys-priority-setretry",
|
||||
strategy: "priority",
|
||||
config: {
|
||||
maxRetries: 0,
|
||||
retryDelayMs: 0,
|
||||
failoverBeforeRetry: true,
|
||||
maxSetRetries: 1,
|
||||
setRetryDelayMs: 500,
|
||||
},
|
||||
models: [provA.model, provB.model],
|
||||
});
|
||||
await combosDb.createCombo({
|
||||
name: "sys-same-server",
|
||||
strategy: "priority",
|
||||
config: { maxRetries: 0, retryDelayMs: 0 },
|
||||
models: [provA.model, provA2.model],
|
||||
});
|
||||
await combosDb.createCombo({
|
||||
name: "sys-same-server-fobr",
|
||||
strategy: "priority",
|
||||
config: { maxRetries: 0, retryDelayMs: 0, failoverBeforeRetry: true },
|
||||
models: [provA.model, provA2.model],
|
||||
});
|
||||
|
||||
await combosDb.createCombo({
|
||||
name: "sys-single-provider",
|
||||
strategy: "priority",
|
||||
config: { maxRetries: 0, retryDelayMs: 0 },
|
||||
models: ["sys-a/modelA", "sys-a/modelB"],
|
||||
});
|
||||
await combosDb.createCombo({
|
||||
name: "sys-single-provider-fobr",
|
||||
strategy: "priority",
|
||||
config: { maxRetries: 0, retryDelayMs: 0, failoverBeforeRetry: true },
|
||||
models: ["sys-a/modelA", "sys-a/modelB"],
|
||||
});
|
||||
|
||||
await settingsDb.updateSettings({
|
||||
resilienceSettings: {
|
||||
requestQueue: {
|
||||
autoEnableApiKeyProviders: true,
|
||||
requestsPerMinute: 120,
|
||||
minTimeBetweenRequestsMs: 0,
|
||||
concurrentRequests: 4,
|
||||
maxWaitMs: 2_000,
|
||||
},
|
||||
connectionCooldown: {
|
||||
oauth: { baseCooldownMs: 500, useUpstreamRetryHints: true, maxBackoffSteps: 3 },
|
||||
apikey: { baseCooldownMs: 200, useUpstreamRetryHints: false, maxBackoffSteps: 0 },
|
||||
},
|
||||
providerBreaker: {
|
||||
oauth: { failureThreshold: 3, resetTimeoutMs: 2_000 },
|
||||
apikey: { failureThreshold: 2, resetTimeoutMs: 1_500 },
|
||||
},
|
||||
waitForCooldown: {
|
||||
enabled: false,
|
||||
maxRetries: 0,
|
||||
maxRetryWaitSec: 0,
|
||||
},
|
||||
},
|
||||
requestRetry: 0,
|
||||
maxRetryIntervalSec: 0,
|
||||
requireLogin: false,
|
||||
setupComplete: true,
|
||||
});
|
||||
|
||||
core.closeDbInstance();
|
||||
|
||||
app = createServerProcess(TEST_DATA_DIR, DASHBOARD_PORT);
|
||||
await waitForServer(app.baseUrl, app);
|
||||
|
||||
const warmup = await postChat(app.baseUrl, "sys-b/test-model", "warm up");
|
||||
assert.equal(warmup.response.status, 200, JSON.stringify(warmup.json));
|
||||
serverB.resetState(TOKEN_B);
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
if (app) await stopProcess(app.child);
|
||||
await serverA.stop();
|
||||
await serverB.stop();
|
||||
core.closeDbInstance();
|
||||
await fsp.rm(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("primary healthy: request routes to Server A only", async () => {
|
||||
assert.ok(app);
|
||||
serverA.resetState(TOKEN_A);
|
||||
serverB.resetState(TOKEN_B);
|
||||
|
||||
const result = await postChat(app.baseUrl, "sys-priority", "healthy primary");
|
||||
|
||||
assert.equal(result.response.status, 200, JSON.stringify(result.json));
|
||||
assert.equal(result.json.choices[0].message.content, "server A ok");
|
||||
assert.equal(result.json.model, "sys-a/test-model");
|
||||
assert.equal(serverA.getState(TOKEN_A).hits, 1);
|
||||
assert.equal(serverB.getState(TOKEN_B).hits, 0);
|
||||
});
|
||||
|
||||
test("500 Internal Server Error: combo falls back to Server B", async () => {
|
||||
assert.ok(app);
|
||||
serverA.resetState(TOKEN_A, [buildError(500, "Internal Server Error")]);
|
||||
serverB.resetState(TOKEN_B);
|
||||
|
||||
const result = await postChat(app.baseUrl, "sys-priority", "500 fallback");
|
||||
|
||||
assert.equal(result.response.status, 200, JSON.stringify(result.json));
|
||||
assert.equal(result.json.choices[0].message.content, "server B ok");
|
||||
assert.equal(result.json.model, "sys-b/test-model");
|
||||
assert.equal(serverA.getState(TOKEN_A).hits, 1);
|
||||
assert.equal(serverB.getState(TOKEN_B).hits, 1);
|
||||
});
|
||||
|
||||
test("503 Service Unavailable: combo falls back to Server B", async () => {
|
||||
assert.ok(app);
|
||||
await resetBreakers(app.baseUrl);
|
||||
resetConnectionCooldowns();
|
||||
serverA.resetState(TOKEN_A, [buildError(503, "Service Unavailable")]);
|
||||
serverB.resetState(TOKEN_B);
|
||||
|
||||
const result = await postChat(app.baseUrl, "sys-priority", "503 fallback");
|
||||
|
||||
assert.equal(result.response.status, 200, JSON.stringify(result.json));
|
||||
assert.equal(result.json.choices[0].message.content, "server B ok");
|
||||
assert.equal(result.json.model, "sys-b/test-model");
|
||||
assert.equal(serverA.getState(TOKEN_A).hits, 1);
|
||||
assert.equal(serverB.getState(TOKEN_B).hits, 1);
|
||||
});
|
||||
|
||||
test("both servers fail (500): request returns a 5xx error to the client", async () => {
|
||||
assert.ok(app);
|
||||
await resetBreakers(app.baseUrl);
|
||||
resetConnectionCooldowns();
|
||||
serverA.resetState(TOKEN_A, [buildError(500, "A is down")]);
|
||||
serverB.resetState(TOKEN_B, [buildError(500, "B is down")]);
|
||||
|
||||
const result = await postChat(app.baseUrl, "sys-priority", "both down");
|
||||
|
||||
assert.ok(result.response.status >= 500, `expected 5xx, got ${result.response.status}`);
|
||||
});
|
||||
|
||||
test("combo fallback to Server B survives sequential 503 failures from Server A", async () => {
|
||||
assert.ok(app);
|
||||
await resetBreakers(app.baseUrl);
|
||||
resetConnectionCooldowns();
|
||||
serverA.resetState(TOKEN_A, [buildError(503, "transient blip"), buildError(503, "second blip")]);
|
||||
serverB.resetState(TOKEN_B);
|
||||
|
||||
const first = await postChat(app.baseUrl, "sys-priority", "seq 503 attempt 1");
|
||||
assert.equal(first.response.status, 200, JSON.stringify(first.json));
|
||||
assert.equal(first.json.choices[0].message.content, "server B ok");
|
||||
assert.equal(first.json.model, "sys-b/test-model");
|
||||
|
||||
// Wait for the 200ms apikey cooldown to expire so the second request also
|
||||
// goes through the full A→B fallback path rather than skipping A entirely.
|
||||
await sleep(250);
|
||||
|
||||
const second = await postChat(app.baseUrl, "sys-priority", "seq 503 attempt 2");
|
||||
assert.equal(second.response.status, 200, JSON.stringify(second.json));
|
||||
assert.equal(second.json.choices[0].message.content, "server B ok");
|
||||
assert.equal(second.json.model, "sys-b/test-model");
|
||||
|
||||
assert.equal(serverA.getState(TOKEN_A).hits, 2);
|
||||
assert.equal(serverB.getState(TOKEN_B).hits, 2);
|
||||
});
|
||||
|
||||
test("429 with Retry-After and wait-for-cooldown: primary retries then falls back to B", async () => {
|
||||
assert.ok(app);
|
||||
await resetBreakers(app.baseUrl);
|
||||
resetConnectionCooldowns();
|
||||
serverA.resetState(TOKEN_A, [
|
||||
buildError(429, "rate limited, retry after 1s", { "Retry-After": "1" }),
|
||||
]);
|
||||
serverB.resetState(TOKEN_B);
|
||||
|
||||
const patchRes = await fetch(`${app.baseUrl}/api/resilience`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
connectionCooldown: {
|
||||
apikey: { useUpstreamRetryHints: true, baseCooldownMs: 200 },
|
||||
},
|
||||
waitForCooldown: { enabled: true, maxRetries: 1, maxRetryWaitSec: 2 },
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
});
|
||||
|
||||
test("failoverBeforeRetry enabled: upstream error triggers immediate failover to next target", async () => {
|
||||
assert.ok(app);
|
||||
await resetBreakers(app.baseUrl);
|
||||
resetConnectionCooldowns();
|
||||
serverA.resetState(TOKEN_A, [buildError(429, "rate limited")]);
|
||||
serverB.resetState(TOKEN_B);
|
||||
|
||||
const result = await postChat(app.baseUrl, "sys-priority-fobr", "test failover before retry");
|
||||
|
||||
assert.equal(result.response.status, 200, JSON.stringify(result.json));
|
||||
assert.equal(result.json.choices[0].message.content, "server B ok");
|
||||
assert.equal(result.json.model, "sys-b/test-model");
|
||||
|
||||
// With failoverBeforeRetry=true, A should be hit exactly ONCE (no intra-URL retry)
|
||||
assert.equal(serverA.getState(TOKEN_A).hits, 1);
|
||||
assert.equal(serverB.getState(TOKEN_B).hits, 1);
|
||||
});
|
||||
|
||||
test("failoverBeforeRetry disabled: 429 triggers executor intra-URL retry, succeeds on retry", async () => {
|
||||
assert.ok(app);
|
||||
// Full resilience reset so A isn't blocked by residual breaker/cooldown state
|
||||
const patchRes = await fetch(`${app.baseUrl}/api/resilience`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
connectionCooldown: {
|
||||
apikey: { useUpstreamRetryHints: false, baseCooldownMs: 0, maxBackoffSteps: 0 },
|
||||
oauth: { useUpstreamRetryHints: false, baseCooldownMs: 0, maxBackoffSteps: 0 },
|
||||
},
|
||||
waitForCooldown: { enabled: false, maxRetries: 0, maxRetryWaitSec: 0 },
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
assert.equal(patchRes.status, 200);
|
||||
await resetBreakers(app.baseUrl);
|
||||
resetConnectionCooldowns();
|
||||
// Wait for any residual cooldowns to expire
|
||||
await sleep(300);
|
||||
// One 429, then the default (200) on retry
|
||||
serverA.resetState(TOKEN_A, [buildError(429, "rate limited")]);
|
||||
serverB.resetState(TOKEN_B);
|
||||
|
||||
const result = await postChat(app.baseUrl, "sys-priority", "test failover before retry disabled");
|
||||
|
||||
assert.equal(result.response.status, 200, JSON.stringify(result.json));
|
||||
|
||||
// With maxRetries=0 the combo does not retry A — it fails over to B immediately.
|
||||
assert.equal(result.json.model, "sys-b/test-model");
|
||||
|
||||
// With failoverBeforeRetry=false, A should be hit TWICE (initial + 1 intra-URL retry).
|
||||
// This contrasts with failoverBeforeRetry=true where A is hit exactly ONCE.
|
||||
assert.equal(serverA.getState(TOKEN_A).hits, 2);
|
||||
});
|
||||
|
||||
test("maxSetRetries: both A and B fail first pass, A 429 again, B 200 on retry", async () => {
|
||||
assert.ok(app);
|
||||
// Reset to a clean resilience slate: disable cooldowns, disable waitForCooldown,
|
||||
// reset breakers, and clear connection rate_limited_until from previous tests.
|
||||
const patchRes = await fetch(`${app.baseUrl}/api/resilience`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
connectionCooldown: {
|
||||
apikey: { useUpstreamRetryHints: false, baseCooldownMs: 0, maxBackoffSteps: 0 },
|
||||
oauth: { useUpstreamRetryHints: false, baseCooldownMs: 0, maxBackoffSteps: 0 },
|
||||
},
|
||||
waitForCooldown: { enabled: false, maxRetries: 0, maxRetryWaitSec: 0 },
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
assert.equal(patchRes.status, 200);
|
||||
await resetBreakers(app.baseUrl);
|
||||
resetConnectionCooldowns();
|
||||
// Set try 0: A 429, B 500 — both fail
|
||||
// Set try 1: A 429, B 200 — B succeeds
|
||||
serverA.resetState(TOKEN_A, [buildError(429, "rate limited"), buildError(429, "rate limited")]);
|
||||
serverB.resetState(TOKEN_B, [
|
||||
buildError(500, "server error"),
|
||||
buildCompletion("server B ok on retry", { model: "sys-b/test-model" }),
|
||||
]);
|
||||
|
||||
const result = await postChat(app.baseUrl, "sys-priority-setretry", "test max set retries", {
|
||||
"x-internal-test": "combo-health-check",
|
||||
});
|
||||
|
||||
// Set try 0: A 429, B 500 → both fail
|
||||
// Set try 1: A 429, B 200 → B succeeds
|
||||
assert.equal(result.response.status, 200, JSON.stringify(result.json));
|
||||
assert.equal(result.json.choices[0].message.content, "server B ok on retry");
|
||||
assert.equal(result.json.model, "sys-b/test-model");
|
||||
assert.equal(serverA.getState(TOKEN_A).hits, 2);
|
||||
assert.equal(serverB.getState(TOKEN_B).hits, 2);
|
||||
|
||||
// Restore defaults so other tests are not affected
|
||||
await fetch(`${app.baseUrl}/api/resilience`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
connectionCooldown: {
|
||||
apikey: { useUpstreamRetryHints: false, baseCooldownMs: 200, maxBackoffSteps: 0 },
|
||||
oauth: { useUpstreamRetryHints: true, baseCooldownMs: 500, maxBackoffSteps: 3 },
|
||||
},
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
});
|
||||
|
||||
test("same server failoverBeforeRetry disabled: first model 429 retried before trying second", async () => {
|
||||
assert.ok(app);
|
||||
// Full resilience reset — previous test restored defaults and may have left A cooldown
|
||||
const patchRes = await fetch(`${app.baseUrl}/api/resilience`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
connectionCooldown: {
|
||||
apikey: { useUpstreamRetryHints: false, baseCooldownMs: 0, maxBackoffSteps: 0 },
|
||||
oauth: { useUpstreamRetryHints: false, baseCooldownMs: 0, maxBackoffSteps: 0 },
|
||||
},
|
||||
waitForCooldown: { enabled: false, maxRetries: 0, maxRetryWaitSec: 0 },
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
assert.equal(patchRes.status, 200);
|
||||
await sleep(300);
|
||||
await resetBreakers(app.baseUrl);
|
||||
resetConnectionCooldowns();
|
||||
serverA.resetState(TOKEN_A, [buildError(429, "rate limited")]);
|
||||
serverA.resetState(TOKEN_A2);
|
||||
|
||||
const result = await postChat(
|
||||
app.baseUrl,
|
||||
"sys-same-server",
|
||||
"test same server failover disabled"
|
||||
);
|
||||
|
||||
assert.equal(result.response.status, 200, JSON.stringify(result.json));
|
||||
|
||||
// With maxRetries=0 the combo fails over to A2 rather than retrying A.
|
||||
assert.equal(result.json.model, "sys-a2/test-model");
|
||||
assert.equal(serverA.getState(TOKEN_A).hits, 2);
|
||||
});
|
||||
|
||||
test("same server failoverBeforeRetry enabled: first model 429 skipped to second immediately", async () => {
|
||||
assert.ok(app);
|
||||
await sleep(300);
|
||||
await resetBreakers(app.baseUrl);
|
||||
resetConnectionCooldowns();
|
||||
serverA.resetState(TOKEN_A, [buildError(429, "rate limited")]);
|
||||
serverA.resetState(TOKEN_A2);
|
||||
|
||||
const result = await postChat(
|
||||
app.baseUrl,
|
||||
"sys-same-server-fobr",
|
||||
"test same server failover enabled"
|
||||
);
|
||||
|
||||
assert.equal(result.response.status, 200, JSON.stringify(result.json));
|
||||
assert.equal(result.json.model, "sys-a2/test-model");
|
||||
assert.equal(serverA.getState(TOKEN_A).hits, 1);
|
||||
assert.equal(serverA.getState(TOKEN_A2).hits, 1);
|
||||
});
|
||||
|
||||
test("single provider, modelA 500: combo fails over to modelB", async () => {
|
||||
assert.ok(app);
|
||||
await resetBreakers(app.baseUrl);
|
||||
resetConnectionCooldowns();
|
||||
serverA.resetState(TOKEN_A, [buildError(500, "model A error")]);
|
||||
|
||||
const result = await postChat(app.baseUrl, "sys-single-provider", "test modelA 500");
|
||||
|
||||
assert.equal(result.response.status, 200, JSON.stringify(result.json));
|
||||
assert.equal(result.json.model, "sys-a/test-model");
|
||||
assert.equal(serverA.getState(TOKEN_A).hits, 2);
|
||||
});
|
||||
|
||||
test("single provider, modelA 503: combo fails over to modelB", async () => {
|
||||
assert.ok(app);
|
||||
await resetBreakers(app.baseUrl);
|
||||
resetConnectionCooldowns();
|
||||
serverA.resetState(TOKEN_A, [buildError(503, "Service Unavailable")]);
|
||||
|
||||
const result = await postChat(app.baseUrl, "sys-single-provider", "test modelA 503");
|
||||
|
||||
assert.equal(result.response.status, 200, JSON.stringify(result.json));
|
||||
assert.equal(result.json.model, "sys-a/test-model");
|
||||
assert.equal(serverA.getState(TOKEN_A).hits, 2);
|
||||
});
|
||||
|
||||
test("single provider, modelA 429 with fobr: immediate failover to modelB", async () => {
|
||||
assert.ok(app);
|
||||
await resetBreakers(app.baseUrl);
|
||||
resetConnectionCooldowns();
|
||||
serverA.resetState(TOKEN_A, [buildError(429, "rate limited")]);
|
||||
|
||||
const result = await postChat(app.baseUrl, "sys-single-provider-fobr", "test fobr");
|
||||
|
||||
assert.equal(result.response.status, 200, JSON.stringify(result.json));
|
||||
assert.equal(result.json.model, "sys-a/test-model");
|
||||
assert.equal(serverA.getState(TOKEN_A).hits, 2);
|
||||
});
|
||||
|
||||
test("single provider, modelA 500 with fobr: modelA retry", async () => {
|
||||
assert.ok(app);
|
||||
await resetBreakers(app.baseUrl);
|
||||
resetConnectionCooldowns();
|
||||
serverA.resetState(TOKEN_A, [buildError(500, "Oops!")]);
|
||||
|
||||
const result = await postChat(app.baseUrl, "sys-single-provider-fobr", "test fobr");
|
||||
|
||||
assert.equal(result.response.status, 200, JSON.stringify(result.json));
|
||||
assert.equal(result.json.model, "sys-a/test-model");
|
||||
assert.equal(serverA.getState(TOKEN_A).hits, 2);
|
||||
});
|
||||
|
||||
test("single provider, both models fail: request returns 5xx to client", async () => {
|
||||
assert.ok(app);
|
||||
await resetBreakers(app.baseUrl);
|
||||
resetConnectionCooldowns();
|
||||
serverA.resetState(TOKEN_A, [buildError(500, "modelA down"), buildError(500, "modelB down")]);
|
||||
|
||||
const result = await postChat(app.baseUrl, "sys-single-provider", "both down");
|
||||
|
||||
assert.ok(result.response.status >= 500, `expected 5xx, got ${result.response.status}`);
|
||||
assert.equal(serverA.getState(TOKEN_A).hits, 2);
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* E2E — Traffic Inspector page smoke tests
|
||||
*
|
||||
* These tests require the OmniRoute server to be running at the configured base URL.
|
||||
* The WebSocket tests use a mock event source rather than a live MITM capture to
|
||||
* avoid needing port 443 privileges.
|
||||
*
|
||||
* CI behaviour: marked `.skip` unless `RUN_TRAFFIC_INSPECTOR_E2E=1` is set.
|
||||
*
|
||||
* To run locally:
|
||||
* RUN_TRAFFIC_INSPECTOR_E2E=1 npx playwright test tests/e2e/traffic-inspector.spec.ts
|
||||
*/
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
|
||||
const SKIP = !process.env["RUN_TRAFFIC_INSPECTOR_E2E"];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function navigateToInspector(page: Page): Promise<void> {
|
||||
await page.goto("/dashboard/tools/traffic-inspector");
|
||||
await page.waitForLoadState("networkidle");
|
||||
}
|
||||
|
||||
async function isAuthenticated(page: Page): Promise<boolean> {
|
||||
return !page.url().includes("/login");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Traffic Inspector page", () => {
|
||||
test.skip(SKIP, "Set RUN_TRAFFIC_INSPECTOR_E2E=1 to run Traffic Inspector E2E tests");
|
||||
|
||||
test("page renders with heading", async ({ page }) => {
|
||||
await navigateToInspector(page);
|
||||
if (!(await isAuthenticated(page))) {
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
return;
|
||||
}
|
||||
await expect(
|
||||
page.locator("h1, [data-testid='traffic-inspector-heading']").first()
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("capture sources toolbar is visible", async ({ page }) => {
|
||||
await navigateToInspector(page);
|
||||
if (!(await isAuthenticated(page))) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
const toolbar = page.locator(
|
||||
"[data-testid='capture-sources-toolbar'], [data-testid='capture-toolbar']"
|
||||
).first();
|
||||
await expect(toolbar).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("filter bar is visible (profile selector + pause + clear)", async ({ page }) => {
|
||||
await navigateToInspector(page);
|
||||
if (!(await isAuthenticated(page))) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
// Profile selector
|
||||
const profileSelector = page.locator(
|
||||
"[data-testid='profile-selector'], [aria-label*='profile'], text=LLM only"
|
||||
).first();
|
||||
await expect(profileSelector).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Pause or Clear button should exist
|
||||
const pauseOrClear = page.locator(
|
||||
"button:has-text('Pause'), button:has-text('Clear'), [data-testid='pause-btn'], [data-testid='clear-btn']"
|
||||
).first();
|
||||
await expect(pauseOrClear).toBeVisible();
|
||||
});
|
||||
|
||||
test("request list panel renders (may be empty)", async ({ page }) => {
|
||||
await navigateToInspector(page);
|
||||
if (!(await isAuthenticated(page))) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
// Left panel should be present (virtualized list container)
|
||||
const requestList = page.locator(
|
||||
"[data-testid='request-list'], [data-testid='streaming-list']"
|
||||
).first();
|
||||
await expect(requestList).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("detail pane and tabs are visible", async ({ page }) => {
|
||||
await navigateToInspector(page);
|
||||
if (!(await isAuthenticated(page))) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
const detailPane = page.locator(
|
||||
"[data-testid='detail-pane'], [data-testid='details-panel']"
|
||||
).first();
|
||||
await expect(detailPane).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// At least Headers and Request tabs should exist
|
||||
const headersTab = page.locator(
|
||||
"button[role='tab']:has-text('Headers'), [data-testid='tab-headers']"
|
||||
).first();
|
||||
await expect(headersTab).toBeVisible();
|
||||
|
||||
const requestTab = page.locator(
|
||||
"button[role='tab']:has-text('Request'), [data-testid='tab-request']"
|
||||
).first();
|
||||
await expect(requestTab).toBeVisible();
|
||||
});
|
||||
|
||||
test("clicking a request row (if any) updates the detail pane", async ({ page }) => {
|
||||
await navigateToInspector(page);
|
||||
if (!(await isAuthenticated(page))) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
// Wait for any rows to appear (up to 5s)
|
||||
const firstRow = page.locator(
|
||||
"[data-testid='request-row'], [data-testid='request-list'] > *"
|
||||
).first();
|
||||
const hasRows = await firstRow.isVisible({ timeout: 5000 }).catch(() => false);
|
||||
if (!hasRows) {
|
||||
// Empty buffer — skip row interaction test
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
await firstRow.click();
|
||||
// Detail pane should now show non-empty content
|
||||
const detailPane = page.locator("[data-testid='detail-pane'], [data-testid='details-panel']").first();
|
||||
await expect(detailPane).not.toBeEmpty();
|
||||
});
|
||||
|
||||
test("Record session button starts recording", async ({ page }) => {
|
||||
await navigateToInspector(page);
|
||||
if (!(await isAuthenticated(page))) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
const recBtn = page.locator(
|
||||
"button:has-text('Record session'), button:has-text('REC'), [data-testid='rec-btn']"
|
||||
).first();
|
||||
const isVisible = await recBtn.isVisible().catch(() => false);
|
||||
if (!isVisible) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
await recBtn.click();
|
||||
// Should show recording indicator
|
||||
const recIndicator = page.locator(
|
||||
"[data-testid='rec-indicator'], text=REC, [data-testid='session-recorder-bar']"
|
||||
).first();
|
||||
await expect(recIndicator).toBeVisible({ timeout: 3000 });
|
||||
|
||||
// Stop recording
|
||||
const stopBtn = page.locator(
|
||||
"button:has-text('Stop'), [data-testid='stop-rec-btn']"
|
||||
).first();
|
||||
if (await stopBtn.isVisible().catch(() => false)) {
|
||||
await stopBtn.click();
|
||||
}
|
||||
});
|
||||
|
||||
test("Export .har button is present", async ({ page }) => {
|
||||
await navigateToInspector(page);
|
||||
if (!(await isAuthenticated(page))) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
const exportBtn = page.locator(
|
||||
"button:has-text('.har'), button:has-text('Export'), [data-testid='export-har-btn']"
|
||||
).first();
|
||||
await expect(exportBtn).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("WebSocket live indicator is shown (connected or disconnected)", async ({ page }) => {
|
||||
await navigateToInspector(page);
|
||||
if (!(await isAuthenticated(page))) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
// Either a green "live" dot or a "disconnected" message should be visible
|
||||
const liveIndicator = page.locator(
|
||||
"[data-testid='ws-status'], text=live, text=Disconnected, [data-testid='live-indicator']"
|
||||
).first();
|
||||
await expect(liveIndicator).toBeVisible({ timeout: 8000 });
|
||||
});
|
||||
|
||||
test("Replay button appears when a request is selected", async ({ page }) => {
|
||||
await navigateToInspector(page);
|
||||
if (!(await isAuthenticated(page))) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
const firstRow = page.locator(
|
||||
"[data-testid='request-row']"
|
||||
).first();
|
||||
const hasRows = await firstRow.isVisible({ timeout: 5000 }).catch(() => false);
|
||||
if (!hasRows) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
await firstRow.click();
|
||||
const replayBtn = page.locator(
|
||||
"button:has-text('Replay'), [data-testid='replay-btn']"
|
||||
).first();
|
||||
await expect(replayBtn).toBeVisible({ timeout: 3000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* E2E (Playwright) — Translator friendly redesign (plano 19)
|
||||
*
|
||||
* Validates that `/dashboard/translator` now renders the 2-tab shell
|
||||
* (Translate + Monitor) with the concept card, deep-link support, and
|
||||
* that the simple mode flow narrates the result through the existing
|
||||
* `/api/translator/*` endpoints (mocked).
|
||||
*
|
||||
* Run with: npm run test:e2e -- tests/e2e/translator-friendly.spec.ts
|
||||
*/
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
|
||||
|
||||
const TIMEOUT_MS = 300_000;
|
||||
|
||||
test.describe("Translator friendly redesign (plano 19)", () => {
|
||||
test.setTimeout(600_000);
|
||||
|
||||
test("renders two tabs (Translate + Monitor) and the concept card", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/translator", { timeoutMs: TIMEOUT_MS });
|
||||
|
||||
// ConceptCard exposes a "How it works" disclosure button (or its PT counterpart).
|
||||
await expect(
|
||||
page.getByRole("button", { name: /how it works|como funciona/i }).first()
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// The shell renders a SegmentedControl with role="tablist" that holds the 2 tabs.
|
||||
await expect(page.getByRole("tab", { name: /^translate$/i }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByRole("tab", { name: /^monitor$/i }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("clicking the Monitor tab swaps content and pushes ?tab=monitor", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/translator", { timeoutMs: TIMEOUT_MS });
|
||||
|
||||
await page.getByRole("tab", { name: /^monitor$/i }).first().click();
|
||||
await expect(page).toHaveURL(/tab=monitor/, { timeout: 10_000 });
|
||||
|
||||
// MonitorTab origin hint or stats card should now be visible.
|
||||
await expect(
|
||||
page
|
||||
.getByText(/events generated|eventos gerados|recent translations|total translations/i)
|
||||
.first()
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("simple mode: typing input + clicking submit shows a narrated result (mocked)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/api/translator/detect", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ success: true, format: "claude" }),
|
||||
})
|
||||
);
|
||||
await page.route("**/api/translator/translate", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
result: { messages: [{ role: "assistant", content: "ok" }] },
|
||||
}),
|
||||
})
|
||||
);
|
||||
await page.route("**/api/translator/send", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: 'data: {"choices":[{"delta":{"content":"hello"}}]}\n\ndata: [DONE]\n\n',
|
||||
})
|
||||
);
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/translator", { timeoutMs: TIMEOUT_MS });
|
||||
|
||||
await page.locator("textarea").first().fill("Olá, quem é você?");
|
||||
await page
|
||||
.getByRole("button", { name: /send and see response|enviar|translate now/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// narratedSuccess / narratedDetected use the keys "translated"/"detected" in EN
|
||||
// and "traduzido"/"detectado" in PT-BR.
|
||||
await expect(
|
||||
page.getByText(/translated|traduzido|detected|detectado/i).first()
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("deep-link ?advanced=streamtransform expands the Stream Transformer accordion", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/translator?advanced=streamtransform", {
|
||||
timeoutMs: TIMEOUT_MS,
|
||||
});
|
||||
|
||||
await expect(
|
||||
page.getByText(/stream transformer/i).first()
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("deep-link ?tab=translate&advanced=testbench expands Test Bench accordion", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoDashboardRoute(
|
||||
page,
|
||||
"/dashboard/translator?tab=translate&advanced=testbench",
|
||||
{ timeoutMs: TIMEOUT_MS }
|
||||
);
|
||||
|
||||
await expect(page.getByText(/test bench/i).first()).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const visualRoutes = ["/400", "/500", "/status", "/offline", "/maintenance"];
|
||||
|
||||
test.describe("Visual Smoke — Resilience Routes", () => {
|
||||
for (const route of visualRoutes) {
|
||||
test(`${route} renders stable viewport without horizontal overflow`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.goto(route);
|
||||
|
||||
const screenshotBuffer = await page.screenshot({ fullPage: false });
|
||||
expect(screenshotBuffer.byteLength).toBeGreaterThan(10_000);
|
||||
|
||||
const hasOverflow = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth > window.innerWidth + 1
|
||||
);
|
||||
expect(hasOverflow).toBeFalsy();
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user