Publish curated ITWorx_MCP_Hub source from 6c7434368ce2a594005983c5a6601a31a639e7f1
This commit is contained in:
1 parent
77946d62c4
commit
6d40ab975c
86 files changed
+6784
-1478
No files matched your search
@@ -10,12 +10,32 @@ describe("management API safety contracts", () => {
|
||||
await createApiClient().resource("tools", { filter: "risk:R1", limit: 25 });
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/tools?q=risk%3AR1&limit=25", expect.objectContaining({ credentials: "same-origin", headers: { Accept: "application/json" } }));
|
||||
});
|
||||
it("maps protected client actions and never invents returned secret material", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ clientId: "client-1", endpoint: "https://mcp.example.test/sse", scopes: ["tools:read"] }) });
|
||||
it("projects only safe client profile fields from the generated configuration", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({
|
||||
clientId: "client-1",
|
||||
kind: "stdio-bridge",
|
||||
protocolVersion: "2026-07-28",
|
||||
endpoint: "https://mcp.example.test/mcp/ludarium/prod",
|
||||
transport: "stdio",
|
||||
scopes: ["tools:read"],
|
||||
credentialReference: "authentik://mcp-client/private-reference",
|
||||
environmentVariable: "ITWORX_MCP_ACCESS_TOKEN",
|
||||
bridgeCommand: "itworx-mcp-bridge",
|
||||
}) });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const config = await createApiClient().generatedConfig("client-1");
|
||||
expect(config).toEqual({ clientId: "client-1", endpoint: "https://mcp.example.test/sse", scopes: ["tools:read"] });
|
||||
expect(JSON.stringify(config)).not.toContain("secret");
|
||||
expect(config).toEqual({
|
||||
clientId: "client-1",
|
||||
kind: "stdio-bridge",
|
||||
protocolVersion: "2026-07-28",
|
||||
endpoint: "https://mcp.example.test/mcp/ludarium/prod",
|
||||
transport: "stdio",
|
||||
scopes: ["tools:read"],
|
||||
environmentVariable: "ITWORX_MCP_ACCESS_TOKEN",
|
||||
bridgeCommand: "itworx-mcp-bridge",
|
||||
});
|
||||
expect(config).not.toHaveProperty("credentialReference");
|
||||
expect(JSON.stringify(config)).not.toContain("authentik://mcp-client/private-reference");
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toEqual(expect.objectContaining({ credentials: "same-origin" }));
|
||||
});
|
||||
it("fails closed without CSRF and sends the session-bound token on writes", async () => {
|
||||
|
||||
@@ -3,6 +3,10 @@ import { isSnapshotStale, routeTitle } from "../src/contracts";
|
||||
import { formatBrusselsTime, healthLabel } from "../src/format";
|
||||
|
||||
describe("management shell contracts", () => {
|
||||
it("labels connector detail breadcrumbs as a registered route", () => {
|
||||
expect(routeTitle("/connectors/knowledge-default")).toBe("Connector detail");
|
||||
expect(routeTitle("/connectors/knowledge-default/")).toBe("Connector detail");
|
||||
});
|
||||
it("maps required routes to stable titles", () => { expect(routeTitle("/")).toBe("Overview"); expect(routeTitle("/projects/ludarium")).toBe("Project detail"); expect(routeTitle("/audit")).toBe("Audit"); expect(routeTitle("/missing")).toBe("Not found"); });
|
||||
it("marks invalid and old snapshots stale instead of implying health", () => { const snapshot = { generatedAt: "2026-08-01T10:00:00Z", staleAfterSeconds: 30 } as Parameters<typeof isSnapshotStale>[0]; expect(isSnapshotStale(snapshot, Date.parse("2026-08-01T10:00:31Z"))).toBe(true); expect(isSnapshotStale({ ...snapshot, generatedAt: "not-a-date" })).toBe(true); });
|
||||
it("formats operator times in Brussels and labels state textually", () => { expect(formatBrusselsTime("2026-08-01T10:00:00Z")).toContain("12:00"); expect(formatBrusselsTime(null)).toBe("Never recorded"); expect(healthLabel("unavailable")).toBe("Unavailable"); });
|
||||
|
||||
@@ -16,8 +16,10 @@ test.describe("authentication", () => {
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await expect(page.getByText("Authenticated", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Governed MCP, with the current signal first." })).toBeVisible();
|
||||
// Real backend data, not a placeholder: Ludarium is the seeded catalog project.
|
||||
await expect(page.getByText("Ludarium", { exact: true })).toBeVisible();
|
||||
// Assert a real bounded dashboard row without depending on catalog ordering.
|
||||
const snapshot = await (await page.request.get("/api/dashboard")).json();
|
||||
expect(snapshot.projects.length).toBeGreaterThan(0);
|
||||
await expect(page.locator(`[href="/projects/${snapshot.projects[0].slug}"]`).first()).toContainText(snapshot.projects[0].name);
|
||||
});
|
||||
|
||||
test("an authenticated user can sign out and stale management data disappears", async ({ page }) => {
|
||||
|
||||
@@ -18,7 +18,8 @@ test.describe("mobile navigation drawer", () => {
|
||||
|
||||
await trigger.click();
|
||||
await expect(page.locator(".sidebar.sidebar-open")).toHaveCount(1);
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(page.locator(".nav-trigger")).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(page.getByRole("dialog", { name: "Application navigation" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("closes via its own close button", async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, loginAs, test } from "./fixtures";
|
||||
|
||||
const darkReview = process.env.WP400_DARK_REVIEW === "1";
|
||||
test.use({ colorScheme: darkReview ? "dark" : "light" });
|
||||
|
||||
const viewports = [
|
||||
{ width: 320, height: 568 }, { width: 360, height: 800 }, { width: 390, height: 844 },
|
||||
{ width: 430, height: 932 }, { width: 768, height: 1024 }, { width: 1280, height: 800 },
|
||||
{ width: 1440, height: 900 }, { width: 844, height: 390 },
|
||||
];
|
||||
|
||||
for (const viewport of viewports) {
|
||||
test(`operator layout and accessible surfaces at ${viewport.width}x${viewport.height}`, async ({ page }, info) => {
|
||||
test.setTimeout(180_000);
|
||||
await page.setViewportSize(viewport);
|
||||
await loginAs(page, "hub-admin");
|
||||
for (const path of ["/", "/projects", "/servers", "/tools", "/connectors", "/clients", "/policies", "/audit", "/operations", "/settings", "/projects/ludarium", "/connectors/knowledge-default"]) {
|
||||
await page.goto(path);
|
||||
await expect(page.locator(".topbar")).toBeVisible();
|
||||
if (viewport.width <= 840) {
|
||||
const target = await page.locator(".nav-trigger").boundingBox();
|
||||
expect(target?.width).toBeGreaterThanOrEqual(44);
|
||||
expect(target?.height).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
await expect(page.locator(".loading-state")).toHaveCount(0, { timeout: 15_000 });
|
||||
const dimensions = await page.evaluate(() => ({ document: document.documentElement.scrollWidth, viewport: innerWidth }));
|
||||
expect(dimensions.document, path).toBeLessThanOrEqual(dimensions.viewport + 1);
|
||||
if (path === "/" && viewport.width <= 430) {
|
||||
await expect(page.getByLabel("Project status records")).toBeVisible();
|
||||
await expect(page.getByLabel("Project status table")).toBeHidden();
|
||||
const grid = await page.locator(".primary-metrics").evaluate((element) => getComputedStyle(element).gridTemplateColumns);
|
||||
expect(grid.split(" ")).toHaveLength(1);
|
||||
}
|
||||
if (path === "/tools" && viewport.width <= 430) await expect(page.getByRole("group", { name: "Table density" })).toBeHidden();
|
||||
if (viewport.width === 390 || viewport.width === 1440) {
|
||||
const result = await new AxeBuilder({ page }).analyze();
|
||||
expect(result.violations.filter((violation) => ["serious", "critical"].includes(violation.impact ?? "")), path).toEqual([]);
|
||||
}
|
||||
if (process.env.SOL_VISUAL_REVIEW && ["/", "/tools", "/clients", "/operations", "/projects/ludarium", "/connectors/knowledge-default"].includes(path)) {
|
||||
const themeSuffix = darkReview ? "-dark" : "";
|
||||
await page.screenshot({ path: info.outputPath(`${path.replaceAll("/", "_") || "dashboard"}-${viewport.width}${themeSuffix}.png`), fullPage: true });
|
||||
await page.screenshot({ path: info.outputPath(`${path.replaceAll("/", "_") || "dashboard"}-${viewport.width}${themeSuffix}-viewport.png`) });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("mobile client actions require an explicit disclosure and confirmation", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await loginAs(page, "hub-admin");
|
||||
await page.goto("/clients");
|
||||
const name = `mobile-safety-${Date.now()}`;
|
||||
await page.getByLabel("Name", { exact: true }).fill(name);
|
||||
await page.getByRole("button", { name: "Create pending client" }).click();
|
||||
const card = page.locator(".mobile-record").filter({ hasText: name }).first();
|
||||
await expect(card.getByRole("button", { name: "Config", exact: true })).toBeVisible();
|
||||
await expect(card.getByRole("button", { name: "Revoke", exact: true })).toBeHidden();
|
||||
await card.locator("summary").click();
|
||||
await card.getByRole("button", { name: "Revoke", exact: true }).click();
|
||||
await expect(page.getByRole("dialog", { name: /Revoke/ })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
});
|
||||
|
||||
test("desktop density persists while mobile cards have no inert density control", async ({ page }) => {
|
||||
await loginAs(page, "hub-viewer");
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.goto("/tools");
|
||||
await page.getByRole("button", { name: "Compact", exact: true }).click();
|
||||
await page.reload();
|
||||
await expect(page.getByRole("button", { name: "Compact", exact: true })).toHaveAttribute("aria-pressed", "true");
|
||||
await page.setViewportSize({ width: 430, height: 932 });
|
||||
await expect(page.getByRole("group", { name: "Table density" })).toBeHidden();
|
||||
});
|
||||
|
||||
test("phone trend keeps outcome details and operations errors never imply no alerts", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await loginAs(page, "hub-viewer");
|
||||
await page.route("**/api/operations/summary", async (route) => {
|
||||
const response = await route.fetch();
|
||||
const summary = await response.json();
|
||||
summary.trend = Array.from({ length: 24 }, (_, hour) => ({ hour: new Date(Date.UTC(2026, 9, 1, hour)).toISOString(), success: hour, denied: 1, error: 0, rate_limited: 0 }));
|
||||
await route.fulfill({ json: summary });
|
||||
});
|
||||
await page.goto("/operations");
|
||||
const mobile = page.getByRole("group", { name: "Four-hour call buckets" });
|
||||
await expect(mobile).toBeVisible();
|
||||
await expect(mobile.getByRole("button")).toHaveCount(6);
|
||||
await mobile.getByRole("button").first().click();
|
||||
await expect(page.locator(".mobile-trend .trend-detail")).toContainText("success");
|
||||
await page.unroute("**/api/operations/summary");
|
||||
await page.route("**/api/operations/summary", (route) => route.fulfill({ status: 503, body: "unavailable" }));
|
||||
await page.reload();
|
||||
await expect(page.getByText("Alerts are unknown while the summary is unavailable.")).toBeVisible();
|
||||
await expect(page.getByText("No alerts in the latest summary")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("status refresh backs off, reports disconnection and does not invoke probes", async ({ page }) => {
|
||||
await loginAs(page, "hub-viewer");
|
||||
await page.clock.install();
|
||||
await page.goto("/");
|
||||
await expect(page.getByText("Current · auto refresh")).toBeVisible();
|
||||
let reads = 0;
|
||||
let writes = 0;
|
||||
page.on("request", (request) => { if (request.method() === "POST" && request.url().includes("/api/")) writes += 1; });
|
||||
await page.route("**/api/dashboard", (route) => { reads += 1; return route.fulfill({ status: 503, body: "unavailable" }); });
|
||||
await page.clock.fastForward(31_000);
|
||||
await expect(page.getByText("Disconnected · retry available")).toBeVisible();
|
||||
expect(reads).toBe(1);
|
||||
await page.clock.fastForward(30_000);
|
||||
expect(reads).toBe(1);
|
||||
await page.clock.fastForward(31_000);
|
||||
await expect.poll(() => reads).toBe(2);
|
||||
expect(writes).toBe(0);
|
||||
});
|
||||
|
||||
test("a hidden dashboard pauses scheduled refresh and never sends an automatic write", async ({ page }) => {
|
||||
await loginAs(page, "hub-viewer");
|
||||
await page.clock.install();
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(document, "hidden", {
|
||||
configurable: true,
|
||||
get: () => sessionStorage.getItem("e2e-document-hidden") !== "false",
|
||||
});
|
||||
});
|
||||
|
||||
let dashboardReads = 0;
|
||||
let apiWrites = 0;
|
||||
page.on("request", (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.pathname === "/api/dashboard" && request.method() === "GET") dashboardReads += 1;
|
||||
if (url.pathname.startsWith("/api/") && request.method() === "POST") apiWrites += 1;
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await expect(page.getByText("Auto refresh paused")).toBeVisible();
|
||||
expect(dashboardReads).toBe(1); // The one initial snapshot; no timer-driven read.
|
||||
|
||||
await page.clock.fastForward(120_000);
|
||||
expect(dashboardReads).toBe(1);
|
||||
expect(apiWrites).toBe(0);
|
||||
});
|
||||
|
||||
test("the dashboard action inbox starts with four signals and expands the rest on demand", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await loginAs(page, "hub-viewer");
|
||||
await page.route("**/api/dashboard", async (route) => {
|
||||
const response = await route.fetch();
|
||||
const snapshot = await response.json();
|
||||
snapshot.generatedAt = new Date().toISOString();
|
||||
snapshot.staleAfterSeconds = 3600;
|
||||
snapshot.health = { hub: "healthy", gateway: "healthy", detail: "Fixture is current." };
|
||||
snapshot.connectors = Array.from({ length: 6 }, (_, index) => ({
|
||||
id: `inbox-fixture-${index + 1}`,
|
||||
name: `Inbox fixture ${index + 1}`,
|
||||
project: "ludarium",
|
||||
state: "degraded",
|
||||
detail: "A controlled connector needs review.",
|
||||
registrationState: "registered",
|
||||
readiness: { state: "unknown", detail: "No live probe was requested.", observedAt: null, source: "not-probed" },
|
||||
}));
|
||||
snapshot.drift = { count: 0, projects: [], known: true, detail: "No drift." };
|
||||
snapshot.backup = { state: "healthy", lastSuccessfulAt: null, detail: "Fixture backup state." };
|
||||
snapshot.denials = { count: 0, window: "24h" };
|
||||
await route.fulfill({ json: snapshot });
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
const inbox = page.getByRole("region", { name: "6 actionable signals" });
|
||||
await expect(inbox.locator(".exception-item")).toHaveCount(4);
|
||||
await expect(inbox.getByRole("link", { name: /Inbox fixture 1/ })).toBeVisible();
|
||||
await expect(inbox.getByRole("link", { name: /Inbox fixture 4/ })).toBeVisible();
|
||||
await expect(inbox.getByRole("link", { name: /Inbox fixture 5/ })).toHaveCount(0);
|
||||
|
||||
await inbox.getByRole("button", { name: "Show 2 more signals" }).click();
|
||||
await expect(inbox.locator(".exception-item")).toHaveCount(6);
|
||||
await expect(inbox.getByRole("link", { name: /Inbox fixture 6/ })).toBeVisible();
|
||||
await inbox.getByRole("button", { name: "Show fewer signals" }).click();
|
||||
await expect(inbox.locator(".exception-item")).toHaveCount(4);
|
||||
});
|
||||
|
||||
test("project and connector detail links resolve to registered records", async ({ page }) => {
|
||||
await loginAs(page, "hub-admin");
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
|
||||
await page.goto("/connectors/knowledge-default");
|
||||
await expect(page.getByRole("heading", { name: "Knowledge Default", exact: true })).toBeVisible();
|
||||
|
||||
await page.goto("/projects/ludarium?tab=MCP%20Servers");
|
||||
await expect(page.getByLabel("MCP servers", { exact: true }).getByRole("heading", { name: "ludarium-prod-readonly", exact: true })).toBeVisible();
|
||||
|
||||
await page.goto("/projects/ludarium?tab=Knowledge%20Sources");
|
||||
await expect(page.getByLabel("Knowledge sources", { exact: true }).getByRole("heading", { name: "discovery://repository/ludarium", exact: true })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { expect, loginAs, test } from "./fixtures";
|
||||
|
||||
test.describe("operator review regressions", () => {
|
||||
test.beforeEach(async ({ page }) => { await loginAs(page, "hub-admin"); });
|
||||
|
||||
test("filter changes retain the URL project and environment at the API boundary", async ({ page }) => {
|
||||
await page.goto("/connectors?project=ludarium&environment=prod");
|
||||
await expect(page.getByLabel("Filter records")).toBeVisible();
|
||||
const response = page.waitForResponse((value) => {
|
||||
const url = new URL(value.url());
|
||||
return url.pathname === "/api/connectors" && url.searchParams.get("q") === "knowledge-default";
|
||||
});
|
||||
await page.getByLabel("Filter records").fill("knowledge-default");
|
||||
const actual = new URL((await response).url());
|
||||
expect(actual.searchParams.get("project")).toBe("ludarium");
|
||||
expect(actual.searchParams.get("environment")).toBe("prod");
|
||||
await expect.poll(() => new URL(page.url()).searchParams.get("q")).toBe("knowledge-default");
|
||||
expect(new URL(page.url()).searchParams.get("project")).toBe("ludarium");
|
||||
expect(new URL(page.url()).searchParams.get("environment")).toBe("prod");
|
||||
await expect(page.getByRole("link", { name: "Knowledge Default", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("catalog scope stays locked until an explicit apply finishes", async ({ page }) => {
|
||||
let release!: () => void;
|
||||
const pending = new Promise<void>((resolve) => { release = resolve; });
|
||||
await page.route("**/api/catalog/preview", (route) => route.fulfill({ json: { valid: true, changes: ["Review fixture change"], errors: [] } }));
|
||||
await page.route("**/api/catalog/apply", async (route) => { await pending; await route.fulfill({ json: { accepted: true, message: "Reviewed catalog apply completed" } }); });
|
||||
await page.goto("/tools?project=ludarium");
|
||||
const workflow = page.locator(".workflow-panel").filter({ has: page.getByRole("heading", { name: "Catalog validation and preview" }) });
|
||||
await workflow.getByRole("button", { name: "Validate and preview" }).click();
|
||||
await workflow.getByRole("button", { name: "Apply approved catalog" }).click();
|
||||
await page.getByRole("button", { name: "Confirm apply" }).click();
|
||||
await expect(workflow.getByRole("combobox", { name: "Project", exact: true })).toBeDisabled();
|
||||
await expect(workflow.getByRole("button", { name: "Applying…" })).toBeDisabled();
|
||||
release();
|
||||
await expect(workflow.getByText("Reviewed catalog apply completed")).toBeVisible();
|
||||
await expect(workflow.getByRole("combobox", { name: "Project", exact: true })).toBeEnabled();
|
||||
});
|
||||
|
||||
test("a pending client created after SPA navigation retains a visible resume path", async ({ page }) => {
|
||||
let release!: () => void;
|
||||
const pending = new Promise<void>((resolve) => { release = resolve; });
|
||||
let createdId = "";
|
||||
await page.route("**/api/client-management", async (route) => {
|
||||
if (route.request().method() !== "POST") { await route.continue(); return; }
|
||||
const response = await route.fetch();
|
||||
createdId = ((await response.json()) as { client: { id: string } }).client.id;
|
||||
await pending;
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
await page.goto("/clients");
|
||||
const name = `creation-navigation-${Date.now()}`;
|
||||
await page.getByLabel("Name", { exact: true }).fill(name);
|
||||
await page.getByRole("button", { name: "Create pending client" }).click();
|
||||
await expect.poll(() => createdId).not.toBe("");
|
||||
await page.getByRole("navigation", { name: "Primary navigation" }).getByRole("link", { name: "Operations", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "Operations", level: 1 })).toBeVisible();
|
||||
release();
|
||||
const notice = page.locator(".client-created-notice");
|
||||
await expect(notice).toContainText(name);
|
||||
expect(new URL(page.url()).pathname).toBe("/operations");
|
||||
await expect(notice.getByRole("link", { name: "Resume onboarding" })).toHaveAttribute("href", `/clients?onboard=${createdId}`);
|
||||
await notice.getByRole("link", { name: "Resume onboarding" }).click();
|
||||
await expect(page.getByText(/Resumable Hub identity/)).toBeVisible();
|
||||
expect(new URL(page.url()).searchParams.get("onboard")).toBe(createdId);
|
||||
});
|
||||
|
||||
test("operation-check results link to registered connector identities", async ({ page }) => {
|
||||
await page.route("**/api/operations/check", (route) => route.fulfill({ json: {
|
||||
generatedAt: new Date().toISOString(), healthy: true,
|
||||
connectors: { "itworx-knowledge": { reachable: true, status: "healthy", detail: "Bounded contract fixture", connectorRefs: [{ id: "knowledge-default", name: "Knowledge Default" }], connectorRefsTruncated: false } },
|
||||
} }));
|
||||
await page.goto("/operations");
|
||||
await page.getByRole("button", { name: "Run live checks" }).click();
|
||||
const registration = page.locator(".connector-checks").getByRole("link", { name: "Knowledge Default", exact: true });
|
||||
await expect(registration).toHaveAttribute("href", "/connectors/knowledge-default");
|
||||
await registration.click();
|
||||
await expect(page.getByRole("heading", { name: "Knowledge Default", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("Quick Jump sends an ordinary client to its filtered records instead of an unavailable onboarding workflow", async ({ page }) => {
|
||||
await page.route("**/api/clients?*", (route) => route.fulfill({ json: {
|
||||
kind: "clients", generatedAt: new Date().toISOString(),
|
||||
rows: [{ id: "ordinary-fixture-client", name: "Ordinary fixture client", status: "healthy", summary: "Existing client", project: "ludarium", environment: "prod" }],
|
||||
page: { total: 1, nextCursor: null },
|
||||
} }));
|
||||
await page.getByRole("button", { name: "Open quick jump" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Quick jump" });
|
||||
await dialog.getByRole("combobox").fill("ordinary-fixture-client");
|
||||
await dialog.getByRole("group", { name: "Clients", exact: true }).getByRole("option", { name: /Ordinary fixture client/ }).click();
|
||||
await expect(page).toHaveURL(/\/clients\?q=ordinary-fixture-client$/);
|
||||
await expect(page.locator(".desktop-data-table").getByText("Existing client", { exact: true })).toBeVisible();
|
||||
const desktopRecord = page.locator(".desktop-data-table").getByRole("link", { name: "Ordinary fixture client", exact: true });
|
||||
await expect(desktopRecord).toHaveAttribute("href", "/clients?q=ordinary-fixture-client");
|
||||
await desktopRecord.click();
|
||||
await expect(page).toHaveURL(/\/clients\?q=ordinary-fixture-client$/);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const mobileRecord = page.locator(".mobile-record-list").getByRole("link", { name: "Ordinary fixture client", exact: true });
|
||||
await expect(mobileRecord).toHaveAttribute("href", "/clients?q=ordinary-fixture-client");
|
||||
await mobileRecord.click();
|
||||
await expect(page).toHaveURL(/\/clients\?q=ordinary-fixture-client$/);
|
||||
await expect(page.getByText(/Resumable Hub identity/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Quick Jump retains the selected audit trace as its destination filter", async ({ page }) => {
|
||||
await page.route("**/api/audit?*", (route) => route.fulfill({ json: {
|
||||
kind: "audit", generatedAt: new Date().toISOString(),
|
||||
rows: [{ id: "audit-fixture", name: "tool.invocation", status: "healthy", summary: "Fixture event", traceId: "review-fixture-trace", project: "ludarium" }],
|
||||
page: { total: 1, nextCursor: null },
|
||||
} }));
|
||||
await page.getByRole("button", { name: "Open quick jump" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Quick jump" });
|
||||
await dialog.getByRole("combobox").fill("review-fixture-trace");
|
||||
await dialog.getByRole("group", { name: "Audit", exact: true }).getByRole("option", { name: /tool.invocation/ }).click();
|
||||
await expect(page).toHaveURL(/\/audit\?q=review-fixture-trace$/);
|
||||
await expect(page.getByLabel("Filter records")).toHaveValue("review-fixture-trace");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { expect, loginAs, test } from "./fixtures";
|
||||
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test.describe("mobile operator shell", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page, "hub-viewer");
|
||||
await page.goto("/");
|
||||
});
|
||||
|
||||
test("navigation isolates the page, traps focus, locks scroll, and restores focus on Escape", async ({ page }, testInfo) => {
|
||||
const trigger = page.getByRole("button", { name: "Open navigation" });
|
||||
const main = page.locator("main");
|
||||
await trigger.focus();
|
||||
await trigger.press("Enter");
|
||||
|
||||
const drawer = page.getByRole("dialog", { name: "Application navigation" });
|
||||
const close = drawer.getByRole("button", { name: "Close navigation" });
|
||||
await expect(drawer).toBeVisible();
|
||||
await expect(drawer).toHaveAttribute("aria-modal", "true");
|
||||
await expect(close).toBeFocused();
|
||||
if (process.env.SOL_VISUAL_REVIEW === "1") await page.screenshot({ path: testInfo.outputPath("mobile-drawer.png"), animations: "disabled" });
|
||||
await expect.poll(() => main.evaluate((element) => (element as HTMLElement).inert)).toBe(true);
|
||||
await expect.poll(() => page.evaluate(() => document.body.style.overflow)).toBe("hidden");
|
||||
|
||||
await drawer.getByRole("link", { name: "ITWorx MCP Hub" }).focus();
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect(drawer.getByRole("link", { name: "Platform" })).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(drawer.getByRole("link", { name: "ITWorx MCP Hub" })).toBeFocused();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(drawer).toHaveCount(0);
|
||||
await expect(trigger).toBeFocused();
|
||||
await expect.poll(() => main.evaluate((element) => (element as HTMLElement).inert)).toBe(false);
|
||||
await expect.poll(() => page.evaluate(() => document.body.style.overflow)).toBe("");
|
||||
});
|
||||
|
||||
test("short phones can scroll the full drawer while the page stays locked", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 320, height: 520 });
|
||||
await page.getByRole("button", { name: "Open navigation" }).click();
|
||||
const drawer = page.getByRole("dialog", { name: "Application navigation" });
|
||||
await expect(drawer).toBeVisible();
|
||||
const metrics = await drawer.evaluate((element) => ({
|
||||
height: element.getBoundingClientRect().height,
|
||||
clientHeight: element.clientHeight,
|
||||
scrollHeight: element.scrollHeight,
|
||||
overflowY: getComputedStyle(element).overflowY,
|
||||
}));
|
||||
expect(metrics.height).toBeLessThanOrEqual(521);
|
||||
expect(metrics.overflowY).toBe("auto");
|
||||
expect(metrics.scrollHeight).toBeGreaterThan(metrics.clientHeight);
|
||||
await drawer.evaluate((element) => { element.scrollTop = element.scrollHeight; });
|
||||
await expect(drawer.getByRole("link", { name: "Platform" })).toBeInViewport();
|
||||
});
|
||||
|
||||
test("Quick Jump searches grouped entities with keyboard control and never requests audit for a viewer", async ({ page }, testInfo) => {
|
||||
const auditRequests: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (new URL(request.url()).pathname === "/api/audit") auditRequests.push(request.url());
|
||||
});
|
||||
await page.getByRole("button", { name: "Open quick jump" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Quick jump" });
|
||||
const input = dialog.getByRole("combobox", { name: "Find a workspace or resource" });
|
||||
await expect(input).toBeFocused();
|
||||
await expect(dialog).toHaveAttribute("aria-modal", "true");
|
||||
await expect.poll(() => page.locator("main").evaluate((element) => Boolean(element.closest("[inert]")))).toBe(true);
|
||||
await input.fill("Ludarium");
|
||||
|
||||
const project = dialog.getByRole("group", { name: "Projects", exact: true }).getByRole("option", { name: /Ludarium/i });
|
||||
await expect(project).toBeVisible();
|
||||
expect((await project.boundingBox())!.height).toBeLessThanOrEqual(120);
|
||||
if (process.env.SOL_VISUAL_REVIEW === "1") await page.screenshot({ path: testInfo.outputPath("mobile-quick-jump.png"), animations: "disabled" });
|
||||
await expect(dialog.getByRole("group", { name: "Projects" })).toBeVisible();
|
||||
await input.press("ArrowDown");
|
||||
await expect(input).toHaveAttribute("aria-activedescendant", /quick-jump-option-/);
|
||||
await input.press("Enter");
|
||||
await expect(page).toHaveURL(/\/projects\/ludarium$/);
|
||||
expect(auditRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test("Quick Jump keeps recent navigation in memory and shows search failures", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Open quick jump" }).click();
|
||||
let dialog = page.getByRole("dialog", { name: "Quick jump" });
|
||||
await dialog.getByRole("option", { name: "Catalog" }).click();
|
||||
await expect(page).toHaveURL(/\/tools$/);
|
||||
|
||||
await page.getByRole("button", { name: "Open quick jump" }).click();
|
||||
dialog = page.getByRole("dialog", { name: "Quick jump" });
|
||||
await expect(dialog.getByRole("group", { name: "Recent destinations" }).getByRole("option", { name: "Catalog" })).toBeVisible();
|
||||
|
||||
await page.route("**/api/projects?*", (route) => route.fulfill({ status: 503, body: "unavailable" }));
|
||||
const input = dialog.getByRole("combobox", { name: "Find a workspace or resource" });
|
||||
await input.fill("Ludarium");
|
||||
await expect(dialog.getByRole("alert")).toHaveText("One resource type could not be searched. Results may be incomplete.");
|
||||
});
|
||||
|
||||
test("sign-out remains explicit and reports its busy state on a narrow screen", async ({ page }) => {
|
||||
await page.route("**/auth/logout", async (route) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
await route.fulfill({ status: 503, body: "unavailable" });
|
||||
});
|
||||
const signOut = page.getByRole("button", { name: "Sign out" });
|
||||
await expect(signOut).toBeVisible();
|
||||
await expect(signOut).toHaveText("Sign out");
|
||||
await signOut.click();
|
||||
const busyButton = page.getByRole("button", { name: "Signing out…" });
|
||||
await expect(busyButton).toBeDisabled();
|
||||
await expect(page.getByRole("alert")).toContainText("control API is unavailable");
|
||||
await expect(page.getByRole("button", { name: "Sign out" })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("mobile workflow dialog", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test("large tool workflow fills the dynamic viewport and scrolls inside the dialog", async ({ page }, testInfo) => {
|
||||
await loginAs(page, "hub-operator");
|
||||
await page.goto("/tools");
|
||||
const trigger = page.locator(".mobile-record-list").getByRole("button", { name: "Readiness" }).first();
|
||||
await trigger.click();
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: /Tool console/ });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect.poll(() => page.evaluate(() => document.body.style.overflow)).toBe("hidden");
|
||||
await expect.poll(() => page.locator("main").evaluate((element) => Boolean(element.closest("[inert]")))).toBe(true);
|
||||
const layout = await dialog.evaluate((element) => ({
|
||||
height: element.getBoundingClientRect().height,
|
||||
overflow: getComputedStyle(element).overflowY,
|
||||
bodyOverflow: getComputedStyle(element.querySelector(".modal-body")!).overflowY,
|
||||
}));
|
||||
expect(layout.height).toBeGreaterThan(800);
|
||||
expect(layout.overflow).toBe("hidden");
|
||||
expect(layout.bodyOverflow).toBe("auto");
|
||||
if (process.env.SOL_VISUAL_REVIEW === "1") {
|
||||
await expect(dialog.getByRole("button", { name: "Run readiness check" })).toBeEnabled();
|
||||
await page.screenshot({ path: testInfo.outputPath("mobile-tool-workflow.png"), animations: "disabled" });
|
||||
}
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(trigger).toBeFocused();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
import { expect, loginAs, test } from "./fixtures";
|
||||
|
||||
function projectDetailFixture(slug: string) {
|
||||
return {
|
||||
slug,
|
||||
name: `Project ${slug.toUpperCase()}`,
|
||||
description: `Delayed route fixture for ${slug}.`,
|
||||
environments: [{ name: "prod", classification: "production" }],
|
||||
capabilityPacks: [],
|
||||
connectorBindings: [],
|
||||
mcpServers: [],
|
||||
knowledgeSources: [],
|
||||
virtualServerCount: 0,
|
||||
clientCount: 0,
|
||||
activeClientCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function connectorDetailFixture(id: string) {
|
||||
return {
|
||||
id,
|
||||
name: `Connector ${id.toUpperCase()}`,
|
||||
connectorType: "fixture",
|
||||
description: `Delayed route fixture for ${id}.`,
|
||||
registration: { state: "registered" as const, detail: "Fixture registration." },
|
||||
readiness: { reachable: null, status: "unknown", detail: "No probe was requested.", observedAt: null, source: "unavailable" as const },
|
||||
bindings: [],
|
||||
relatedTools: [],
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("operator workflow details", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page, "hub-admin");
|
||||
});
|
||||
|
||||
test("client onboarding resumes from its durable identity and never treats a Hub check as MCP proof", async ({ page }) => {
|
||||
await page.goto("/clients");
|
||||
const clientName = `onboarding-resume-${Date.now()}`;
|
||||
await page.getByLabel("Name").fill(clientName);
|
||||
await page.getByLabel("Client profile").selectOption("native-2026");
|
||||
const createResponsePromise = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return response.request().method() === "POST" && url.pathname === "/api/client-management";
|
||||
});
|
||||
await page.getByRole("button", { name: "Create pending client" }).click();
|
||||
const created = await (await createResponsePromise).json() as {
|
||||
client: { id: string };
|
||||
bootstrap: { reference: string };
|
||||
config: { kind: string; protocolVersion: string; transport: string; credentialReference: string };
|
||||
provisioning: { oauthUsername: string; issuer: string; tokenEndpoint: string; oauthClientId: string };
|
||||
};
|
||||
await expect(page.getByText(`Client created: ${clientName}`)).toBeVisible();
|
||||
await expect.poll(() => page.url()).toMatch(/\/clients\?onboard=[0-9a-f-]+$/i);
|
||||
const savedUrl = page.url();
|
||||
expect(new URL(savedUrl).searchParams.get("onboard")).toBe(created.client.id);
|
||||
await page.reload();
|
||||
|
||||
await expect(page.getByText(/Resumable Hub identity/)).toBeVisible();
|
||||
const handoff = page.locator(".onboarding-config-auth");
|
||||
const configPanel = page.locator(".onboarding-config");
|
||||
const configValue = (label: string) => configPanel.locator("dl > div").filter({ hasText: label }).locator("dd");
|
||||
await expect(configValue("Client profile")).toHaveText(created.config.kind);
|
||||
await expect(configValue("Protocol version")).toHaveText(created.config.protocolVersion);
|
||||
await expect(configValue("Transport")).toHaveText(created.config.transport);
|
||||
await expect(configPanel.getByText("Credential environment variable", { exact: true })).toHaveCount(0);
|
||||
await expect(configPanel.getByText("Bridge command", { exact: true })).toHaveCount(0);
|
||||
await expect(handoff).toContainText(`External account: ${created.provisioning.oauthUsername}`);
|
||||
await expect(handoff).toContainText(`Issuer: ${created.provisioning.issuer}`);
|
||||
await expect(handoff).toContainText(`Token endpoint: ${created.provisioning.tokenEndpoint}`);
|
||||
await expect(handoff).toContainText(`OAuth client ID: ${created.provisioning.oauthClientId}`);
|
||||
const resumedText = await page.locator("body").innerText();
|
||||
expect(resumedText).not.toContain(created.bootstrap.reference);
|
||||
expect(resumedText).not.toContain(created.config.credentialReference);
|
||||
await expect(page.getByText("OAuth scopes", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/External verification still required/)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Record Hub checks" })).toBeEnabled();
|
||||
await page.getByRole("button", { name: "Record Hub checks" }).click();
|
||||
|
||||
await expect(page.locator(".onboarding-check-recorded")).toContainText("Hub checks were recorded");
|
||||
await expect(page.getByText(/does not mark external OAuth grants or MCP discovery as complete/)).toBeVisible();
|
||||
await expect(page.locator(".onboarding-identity-bar small")).toContainText("· pending");
|
||||
expect(page.url()).toBe(savedUrl);
|
||||
});
|
||||
|
||||
test("a delayed verify response cannot overwrite the onboarding identity after the route changes", async ({ page }) => {
|
||||
const onboardingStatus = (clientId: string, subjectDetail: string, verified = false) => ({
|
||||
clientId,
|
||||
project: "ludarium",
|
||||
environment: "prod",
|
||||
clientStatus: "pending",
|
||||
provisioning: {
|
||||
status: "pending-authentik-service-account",
|
||||
subject: null,
|
||||
issuer: "https://identity.example.invalid",
|
||||
oauthUsername: `svc-mcp-codex-ludarium-${clientId}`,
|
||||
tokenEndpoint: "https://identity.example.invalid/application/o/token/",
|
||||
oauthClientId: "hub-fixture-client",
|
||||
steps: ["Complete external account setup with the administrator."],
|
||||
},
|
||||
checks: {
|
||||
externalSubject: { state: "pending", detail: subjectDetail },
|
||||
externalScope: { state: "unavailable", detail: "External OAuth grants are not visible to the Hub." },
|
||||
hubBinding: { state: "pending", detail: "The client remains disabled." },
|
||||
catalog: { state: "unknown", detail: "Catalog check has not passed." },
|
||||
connector: { state: "unknown", detail: "Connector state is unknown." , observedAt: null },
|
||||
mcpProof: { state: "unavailable", detail: "MCP discovery and invocation must be confirmed externally." },
|
||||
},
|
||||
verification: { checkedAt: verified ? "2026-10-01T10:00:00.000Z" : null, checkedByManagement: verified, verifiedByManagement: verified, hubChecksPassed: verified, detail: verified ? "Client A checks passed." : "No verification record." },
|
||||
});
|
||||
let releaseVerify!: () => void;
|
||||
const verifyGate = new Promise<void>((resolve) => { releaseVerify = resolve; });
|
||||
await page.route("**/api/client-management/a/onboarding-status", (route) => route.fulfill({ json: onboardingStatus("a", "Client A is awaiting its external subject.") }));
|
||||
await page.route("**/api/client-management/a/config", (route) => route.fulfill({ json: { clientId: "a", endpoint: "https://hub.example.invalid/mcp", scopes: ["project:ludarium:read"] } }));
|
||||
await page.route("**/api/client-management/b/onboarding-status", (route) => route.fulfill({ json: onboardingStatus("b", "Client B is awaiting its external subject.") }));
|
||||
await page.route("**/api/client-management/b/config", (route) => route.fulfill({ json: { clientId: "b", endpoint: "https://hub.example.invalid/mcp", scopes: ["project:ludarium:read"] } }));
|
||||
await page.route("**/api/client-management/a/verify", async (route) => {
|
||||
await verifyGate;
|
||||
await route.fulfill({ json: onboardingStatus("a", "Client A is verified.", true) });
|
||||
});
|
||||
|
||||
await page.goto("/clients?onboard=a");
|
||||
await expect(page.getByText("Client A is awaiting its external subject.")).toBeVisible();
|
||||
const verifyRequest = page.waitForRequest((request) => request.url().endsWith("/api/client-management/a/verify"));
|
||||
await page.getByRole("button", { name: "Record Hub checks" }).click();
|
||||
await verifyRequest;
|
||||
await page.evaluate(() => {
|
||||
window.history.pushState({}, "", "/clients?onboard=b");
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
});
|
||||
await expect(page.getByText("Client B is awaiting its external subject.")).toBeVisible();
|
||||
|
||||
releaseVerify();
|
||||
await expect(page.getByText("Client B is awaiting its external subject.")).toBeVisible();
|
||||
await expect(page.getByText("Client A is verified.")).toHaveCount(0);
|
||||
await expect(page.getByText(/Hub checks passed/)).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/clients\?onboard=b$/);
|
||||
});
|
||||
|
||||
test("a delayed catalog preview for the previous project cannot be shown or applied", async ({ page }) => {
|
||||
await page.route((url) => url.pathname === "/api/projects" && url.searchParams.has("limit"), (route) => route.fulfill({ json: {
|
||||
kind: "projects",
|
||||
generatedAt: "2026-10-01T10:00:00.000Z",
|
||||
rows: [
|
||||
{ id: "ludarium", name: "Ludarium", status: "healthy", summary: "Fixture project A" },
|
||||
{ id: "forgeflow", name: "ForgeFlow", status: "healthy", summary: "Fixture project B" },
|
||||
],
|
||||
page: { total: 2 },
|
||||
} }));
|
||||
|
||||
let releasePreview!: () => void;
|
||||
const previewGate = new Promise<void>((resolve) => { releasePreview = resolve; });
|
||||
const previewProjects: string[] = [];
|
||||
await page.route("**/api/catalog/preview", async (route) => {
|
||||
const body = route.request().postDataJSON() as { project: string };
|
||||
previewProjects.push(body.project);
|
||||
if (body.project === "ludarium") {
|
||||
await previewGate;
|
||||
await route.fulfill({ json: { valid: true, changes: ["Project A private preview"], errors: [] } });
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ json: { valid: true, changes: ["Project B preview"], errors: [] } });
|
||||
});
|
||||
|
||||
await page.goto("/tools");
|
||||
const workflow = page.locator(".workflow-panel").filter({ has: page.getByRole("heading", { name: "Catalog validation and preview" }) });
|
||||
const project = workflow.getByLabel("Project");
|
||||
await expect(project.locator("option")).toHaveCount(2);
|
||||
await project.selectOption("ludarium");
|
||||
|
||||
const previewRequest = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/catalog/preview");
|
||||
await workflow.getByRole("button", { name: "Validate and preview" }).click();
|
||||
await previewRequest;
|
||||
try {
|
||||
await project.selectOption("forgeflow");
|
||||
await expect(workflow.locator(".diff-box")).toHaveCount(0);
|
||||
releasePreview();
|
||||
await expect(workflow.getByText("Project A private preview")).toHaveCount(0);
|
||||
await expect(workflow.getByRole("button", { name: "Apply approved catalog" })).toHaveCount(0);
|
||||
expect(previewProjects).toEqual(["ludarium"]);
|
||||
} finally {
|
||||
releasePreview();
|
||||
}
|
||||
});
|
||||
|
||||
test("a delayed project detail response is ignored after navigating to another project", async ({ page }) => {
|
||||
let releaseProjectA!: () => void;
|
||||
const projectGate = new Promise<void>((resolve) => { releaseProjectA = resolve; });
|
||||
await page.route("**/api/projects/a", async (route) => {
|
||||
await projectGate;
|
||||
await route.fulfill({ json: projectDetailFixture("a") });
|
||||
});
|
||||
await page.route("**/api/projects/b", (route) => route.fulfill({ json: projectDetailFixture("b") }));
|
||||
|
||||
const projectARequest = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/projects/a");
|
||||
await page.goto("/projects/a");
|
||||
await projectARequest;
|
||||
await page.evaluate(() => {
|
||||
window.history.pushState({}, "", "/projects/b");
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
});
|
||||
try {
|
||||
await expect(page.getByRole("heading", { name: "Project B", exact: true })).toBeVisible();
|
||||
releaseProjectA();
|
||||
await expect(page.getByRole("heading", { name: "Project A", exact: true })).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/projects\/b$/);
|
||||
} finally {
|
||||
releaseProjectA();
|
||||
}
|
||||
});
|
||||
|
||||
test("a delayed connector detail response is ignored after navigating to another connector", async ({ page }) => {
|
||||
let releaseConnectorA!: () => void;
|
||||
const connectorGate = new Promise<void>((resolve) => { releaseConnectorA = resolve; });
|
||||
await page.route("**/api/connectors/a", async (route) => {
|
||||
await connectorGate;
|
||||
await route.fulfill({ json: connectorDetailFixture("a") });
|
||||
});
|
||||
await page.route("**/api/connectors/b", (route) => route.fulfill({ json: connectorDetailFixture("b") }));
|
||||
|
||||
const connectorARequest = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/connectors/a");
|
||||
await page.goto("/connectors/a");
|
||||
await connectorARequest;
|
||||
await page.evaluate(() => {
|
||||
window.history.pushState({}, "", "/connectors/b");
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
});
|
||||
try {
|
||||
await expect(page.getByRole("heading", { name: "Connector B", exact: true })).toBeVisible();
|
||||
releaseConnectorA();
|
||||
await expect(page.getByRole("heading", { name: "Connector A", exact: true })).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/connectors\/b$/);
|
||||
} finally {
|
||||
releaseConnectorA();
|
||||
}
|
||||
});
|
||||
|
||||
test("typed tool arguments reach the readiness API and recent runs retain metadata only", async ({ page }) => {
|
||||
const capturedRequests: Array<Record<string, unknown>> = [];
|
||||
await page.route(/\/api\/tools(?:\?.*)?$/, (route) => route.fulfill({ json: {
|
||||
kind: "tools",
|
||||
generatedAt: "2026-10-01T10:00:00.000Z",
|
||||
rows: [{ id: "typed-tool", name: "Typed Tool", status: "healthy", summary: "Typed schema fixture", project: "ludarium", environment: "prod", risk: "R1", canonicalName: "test.typed" }],
|
||||
page: { total: 1 },
|
||||
} }));
|
||||
await page.route("**/api/tool-definitions/test.typed", (route) => route.fulfill({ json: {
|
||||
canonicalName: "test.typed",
|
||||
description: "Typed schema fixture",
|
||||
readOnly: true,
|
||||
riskClass: "R1",
|
||||
inputSchema: { type: "object", required: ["count", "enabled", "mode", "tags", "filters"], properties: {
|
||||
count: { type: "integer", title: "Count", minimum: 1, maximum: 10 },
|
||||
enabled: { type: "boolean", title: "Enabled" },
|
||||
mode: { type: "string", title: "Mode", enum: ["safe", "full"] },
|
||||
tags: { type: "array", title: "Tags" },
|
||||
filters: { type: "object", title: "Filters" },
|
||||
} },
|
||||
limits: { timeoutMs: 1000, maxOutputBytes: 2048, maxItems: 10 },
|
||||
} }));
|
||||
await page.route("**/api/tools/test", async (route) => {
|
||||
capturedRequests.push(route.request().postDataJSON() as Record<string, unknown>);
|
||||
await route.fulfill({ json: {
|
||||
ready: true,
|
||||
tool: "test.typed",
|
||||
project: "ludarium",
|
||||
environment: "prod",
|
||||
note: "Readiness only; no MCP tool invocation was performed.",
|
||||
checks: {
|
||||
catalog: { valid: true, readOnly: true, connectorType: "fixture" },
|
||||
input: { valid: true, errors: [] },
|
||||
binding: { valid: true, detail: "Fixture binding" },
|
||||
policy: { allowed: true, decision: "allow", reasonCode: "FIXTURE_ALLOWED", traceId: "ready-trace" },
|
||||
connector: { reachable: true, status: "healthy", detail: "Fixture route" },
|
||||
},
|
||||
} });
|
||||
});
|
||||
await page.route("**/api/tools/invoke", (route) => route.fulfill({ json: {
|
||||
ok: false,
|
||||
traceId: "trace-typed-001",
|
||||
tool: "test.typed",
|
||||
content: [{ type: "text", text: "controlled fixture result" }],
|
||||
error: { code: "controlled", message: "The fixture returned a controlled error." },
|
||||
} }));
|
||||
|
||||
await page.goto("/tools");
|
||||
await page.getByRole("button", { name: "Readiness" }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog.getByRole("button", { name: "Run readiness check" })).toBeDisabled();
|
||||
await dialog.getByLabel("Project").selectOption("ludarium");
|
||||
await dialog.getByLabel("Environment").selectOption("prod");
|
||||
await dialog.getByLabel("Count").fill("4");
|
||||
await dialog.getByLabel("Enabled").selectOption("true");
|
||||
await dialog.getByLabel("Mode").selectOption({ label: "full" });
|
||||
await dialog.getByLabel("Tags").fill('["never-store-tags"]');
|
||||
await dialog.getByLabel("Filters").fill('{"author":"never-store-filter"}');
|
||||
await dialog.getByRole("button", { name: "Run readiness check" }).click();
|
||||
await expect(dialog.getByText("Ready", { exact: true })).toBeVisible();
|
||||
expect(capturedRequests[0]).toMatchObject({
|
||||
project: "ludarium",
|
||||
environment: "prod",
|
||||
tool: "test.typed",
|
||||
arguments: { count: 4, enabled: true, mode: "full", tags: ["never-store-tags"], filters: { author: "never-store-filter" } },
|
||||
});
|
||||
|
||||
await dialog.getByLabel("Count").fill("5");
|
||||
await expect(dialog.getByText("Ready", { exact: true })).toHaveCount(0);
|
||||
await expect(dialog.getByRole("button", { name: "Run tool" })).toBeDisabled();
|
||||
await dialog.getByRole("button", { name: "Run readiness check" }).click();
|
||||
await expect(dialog.getByText("Ready", { exact: true })).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "Run tool" }).click();
|
||||
await expect(dialog.getByRole("heading", { name: "Controlled error" })).toBeVisible();
|
||||
|
||||
const storedHistory = await page.evaluate(() => sessionStorage.getItem("itworx-tool-history-v1"));
|
||||
expect(storedHistory).toContain("trace-typed-001");
|
||||
expect(storedHistory).not.toContain("never-store-tags");
|
||||
expect(storedHistory).not.toContain("never-store-filter");
|
||||
expect(storedHistory).not.toContain("controlled fixture result");
|
||||
await dialog.getByRole("button", { name: "Close dialog" }).click();
|
||||
|
||||
const recentRuns = page.getByRole("region", { name: "Recent Tool Runs" });
|
||||
await expect(recentRuns.getByText("Controlled error")).toBeVisible();
|
||||
await expect(recentRuns.getByText("trace-typed-001")).toBeVisible();
|
||||
await recentRuns.getByRole("button", { name: "Open tool context" }).click();
|
||||
await expect(page).toHaveURL(/\/tools\?reopen=test\.typed&project=ludarium&environment=prod/);
|
||||
await expect(page.getByRole("dialog", { name: /Tool console/ })).toBeVisible();
|
||||
await expect(page.getByRole("dialog").getByLabel("Project")).toHaveValue("ludarium");
|
||||
await expect(page.getByRole("dialog").getByLabel("Environment")).toHaveValue("prod");
|
||||
});
|
||||
});
|
||||
@@ -33,12 +33,12 @@ test.describe("product-quality interaction and composition", () => {
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "Quick jump" });
|
||||
await expect(dialog).toBeVisible();
|
||||
const search = dialog.getByLabel("Find a workspace");
|
||||
const search = dialog.getByRole("combobox", { name: "Find a workspace or resource" });
|
||||
await expect(search).toBeFocused();
|
||||
await expect(dialog.getByRole("button", { name: /Audit/ })).toHaveCount(0);
|
||||
await expect(dialog.getByRole("option", { name: /Audit/ })).toHaveCount(0);
|
||||
|
||||
await search.fill("catalog");
|
||||
await dialog.getByRole("button", { name: /Catalog/ }).click();
|
||||
await dialog.getByRole("group", { name: "Build", exact: true }).getByRole("option", { name: /Catalog/ }).click();
|
||||
await expect(page).toHaveURL(/\/tools$/);
|
||||
await expect(page.getByRole("heading", { name: "Catalog", level: 2 })).toBeVisible();
|
||||
});
|
||||
@@ -48,7 +48,7 @@ test.describe("product-quality interaction and composition", () => {
|
||||
await page.goto("/tools");
|
||||
const filter = page.getByLabel("Filter records");
|
||||
await filter.fill(" Fleet Ops ");
|
||||
await expect(page).toHaveURL(/\/tools\?q=Fleet%20Ops$/);
|
||||
await expect.poll(() => new URL(page.url()).searchParams.get("q")).toBe("Fleet Ops");
|
||||
await expect(page.getByText("Updating results…")).toHaveCount(0);
|
||||
|
||||
await filter.fill(" ");
|
||||
@@ -79,7 +79,7 @@ test.describe("product-quality interaction and composition", () => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
|
||||
await page.goto("/tools");
|
||||
await expect(page.getByRole("heading", { name: "Catalog", level: 2 })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Catalog", level: 2 })).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByLabel("Filter records")).toBeVisible();
|
||||
await expectNoPageOverflow(page);
|
||||
if (captureReview) await page.screenshot({ path: testInfo.outputPath("catalog-desktop.png"), fullPage: false });
|
||||
|
||||
@@ -29,13 +29,13 @@ test.describe("management workflows, driven end to end from the browser", () =>
|
||||
}) => {
|
||||
await page.goto("/projects/ludarium?tab=Environments");
|
||||
await expect(page.getByLabel("Project detail sections").getByRole("link", { name: "Environments" })).toHaveAttribute("aria-current", "page");
|
||||
await expect(page.getByRole("cell", { name: "prod", exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("Project environments", { exact: true }).getByRole("heading", { name: "prod", exact: true })).toBeVisible();
|
||||
// ludarium never registered a staging environment (docs/known-state, and
|
||||
// tests/test_wp230_ui_completion.py's equivalent backend assertion).
|
||||
await expect(page.getByRole("cell", { name: "staging" })).toHaveCount(0);
|
||||
await expect(page.getByLabel("Project environments", { exact: true }).getByRole("heading", { name: "staging", exact: true })).toHaveCount(0);
|
||||
|
||||
await page.getByLabel("Project detail sections").getByRole("link", { name: "Connectors" }).click();
|
||||
await expect(page.getByRole("cell", { name: "knowledge", exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("Connector bindings", { exact: true }).getByRole("heading", { name: "knowledge", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("an unregistered project is a real 404, not a fabricated default", async ({ page }) => {
|
||||
@@ -63,12 +63,14 @@ test.describe("management workflows, driven end to end from the browser", () =>
|
||||
await page.goto("/tools");
|
||||
await page.getByRole("button", { name: "Validate and preview" }).click();
|
||||
const outcome = page.getByText(/Valid preview|Validation blocked/);
|
||||
await expect(outcome).toBeVisible();
|
||||
await expect(outcome).toBeVisible({ timeout: 15_000 });
|
||||
const applyButton = page.getByRole("button", { name: "Apply approved catalog" });
|
||||
if (await outcome.filter({ hasText: "Valid preview" }).count()) {
|
||||
await expect(applyButton).toBeEnabled();
|
||||
await applyButton.click();
|
||||
await expect(page.getByRole("dialog")).toContainText("All registered project routes may be updated");
|
||||
await page.getByRole("button", { name: "Confirm apply" }).click();
|
||||
await expect(page.getByText(/Catalog reconciliation completed and verified/)).toBeVisible({ timeout: 15_000 });
|
||||
} else {
|
||||
await expect(applyButton).toBeDisabled();
|
||||
}
|
||||
@@ -167,10 +169,10 @@ test.describe("management workflows, driven end to end from the browser", () =>
|
||||
test("tool readiness validates the full registered path without invoking the tool", async ({ page }) => {
|
||||
await page.goto("/tools");
|
||||
await page.getByRole("button", { name: "Validate and preview" }).click();
|
||||
await expect(page.getByText("Valid preview")).toBeVisible();
|
||||
await expect(page.getByText("Valid preview")).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByRole("button", { name: "Apply approved catalog" }).click();
|
||||
await page.getByRole("button", { name: "Confirm apply" }).click();
|
||||
await expect(page.getByText(/Catalog reconciliation completed and verified/)).toBeVisible();
|
||||
await expect(page.getByText(/Catalog reconciliation completed and verified/)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.getByLabel("Filter records").fill("Fleet Ops Operations Summary");
|
||||
const row = page.getByRole("row", { name: /Fleet Ops Operations Summary/ });
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DashboardSnapshot } from "../src/contracts";
|
||||
import { actionInbox, aggregateTrend, alertDestination } from "../src/operator-data";
|
||||
|
||||
const snapshot: DashboardSnapshot = {
|
||||
generatedAt: "2026-10-01T10:00:00Z", staleAfterSeconds: 90,
|
||||
health: { hub: "healthy", gateway: "healthy", detail: "Available" },
|
||||
projects: [], connectors: [{ id: "gitea-alpha", name: "gitea", project: "alpha", state: "degraded", registrationState: "registered", readiness: { state: "unknown", detail: "Not probed", observedAt: null, source: "not-probed" }, detail: "Readiness failed" }],
|
||||
denials: { count: 2, window: "24h", latestTool: "gitea.repository.get" },
|
||||
drift: { count: 1, projects: ["alpha"], known: true, detail: "Catalog differs" },
|
||||
activeClients: 1, backup: { state: "degraded", lastSuccessfulAt: null, detail: "No backup observed" },
|
||||
version: { application: "1.0.0", schema: "1" },
|
||||
};
|
||||
|
||||
describe("operator signals", () => {
|
||||
it("routes a connector signal to its exact project binding context", () => {
|
||||
const item = actionInbox(snapshot, false, false).find((signal) => signal.project === "alpha" && signal.subsystem === "gitea");
|
||||
expect(item?.href).toBe("/connectors/gitea-alpha");
|
||||
expect(item?.detail).toBe("Readiness failed");
|
||||
expect(item?.observedAt).toBe("2026-10-01T10:00:00Z");
|
||||
});
|
||||
it("exposes audit navigation only to an authorized auditor", () => {
|
||||
expect(actionInbox(snapshot, false, false).find((signal) => signal.subsystem === "Authorization")?.href).toBe("/operations?section=alerts");
|
||||
expect(actionInbox(snapshot, false, true).find((signal) => signal.subsystem === "Authorization")?.href).toBe("/audit?q=gitea.repository.get");
|
||||
});
|
||||
it("links drift and backup to their bounded resolution workspace", () => {
|
||||
const signals = actionInbox(snapshot, false, true);
|
||||
expect(signals.find((signal) => signal.subsystem === "Catalog")?.href).toBe("/operations?section=checks");
|
||||
expect(signals.find((signal) => signal.subsystem === "Recovery")?.href).toBe("/operations?section=recovery");
|
||||
expect(alertDestination("BACKUP_STALE", false)).toBe("/operations?section=recovery");
|
||||
});
|
||||
it("keeps unknown and stale state explicit without claiming a diagnosis", () => {
|
||||
const signals = actionInbox({ ...snapshot, drift: { ...snapshot.drift, known: false } }, true, false);
|
||||
expect(signals[0]?.subsystem).toBe("Freshness");
|
||||
expect(signals.find((signal) => signal.subsystem === "Catalog")?.status).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mobile call buckets", () => {
|
||||
it("aggregates each four-hour interval without dropping outcomes", () => {
|
||||
const trend = Array.from({ length: 24 }, (_, hour) => ({ hour: `2026-10-01T${String(hour).padStart(2, "0")}:00:00Z`, success: 1, denied: 2, error: 3, rate_limited: 4 }));
|
||||
const buckets = aggregateTrend(trend, 4);
|
||||
expect(buckets).toHaveLength(6);
|
||||
expect(buckets[0]).toEqual({ hour: "2026-10-01T00:00:00Z", endHour: "2026-10-01T03:00:00Z", success: 4, denied: 8, error: 12, rate_limited: 16 });
|
||||
expect(buckets[5]?.success).toBe(4);
|
||||
});
|
||||
it("retains partial buckets and handles empty observations", () => {
|
||||
expect(aggregateTrend([], 4)).toEqual([]);
|
||||
expect(aggregateTrend([{ hour: "2026-10-01T00:00:00Z", success: 2, denied: 0, error: 1, rate_limited: 0 }], 4)[0]?.error).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { readPreference, writePreference } from "../src/preferences";
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
it("accepts only known presentation preferences", () => {
|
||||
const values = new Map<string, string>([["hub-density", "arbitrary"]]);
|
||||
vi.stubGlobal("localStorage", { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => values.set(key, value) });
|
||||
expect(readPreference("hub-density", ["compact", "comfortable"], "comfortable")).toBe("comfortable");
|
||||
writePreference("hub-density", "compact");
|
||||
expect(readPreference("hub-density", ["compact", "comfortable"], "comfortable")).toBe("compact");
|
||||
});
|
||||
it("continues with safe defaults when storage is denied", () => {
|
||||
vi.stubGlobal("localStorage", { getItem: () => { throw new Error("storage denied"); }, setItem: () => { throw new Error("storage denied"); } });
|
||||
expect(readPreference("hub-theme", ["dark", "light"], "dark")).toBe("dark");
|
||||
expect(() => writePreference("hub-theme", "light")).not.toThrow();
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { NULL_INPUT_VALUE, parseSchemaArguments } from "../src/schema-input";
|
||||
import type { ToolDefinition } from "../src/contracts";
|
||||
|
||||
const schema = (properties: NonNullable<ToolDefinition["inputSchema"]["properties"]>, required: string[] = []): ToolDefinition["inputSchema"] => ({
|
||||
type: "object",
|
||||
properties,
|
||||
required,
|
||||
});
|
||||
|
||||
describe("schema-driven tool arguments", () => {
|
||||
it("parses strings, integers, decimals, booleans, enums, arrays and objects by declared type", () => {
|
||||
const inputSchema = schema({
|
||||
query: { type: "string" },
|
||||
limit: { type: "integer", minimum: 1, maximum: 20 },
|
||||
score: { type: "number" },
|
||||
includeClosed: { type: "boolean" },
|
||||
format: { type: "string", enum: ["compact", "full"] },
|
||||
labels: { type: "array" },
|
||||
filters: { type: "object" },
|
||||
}, ["query", "limit", "includeClosed", "format"]);
|
||||
|
||||
expect(parseSchemaArguments({
|
||||
query: "pull request",
|
||||
limit: "4",
|
||||
score: "3.5",
|
||||
includeClosed: "false",
|
||||
format: JSON.stringify("full"),
|
||||
labels: "[\"bug\",\"urgent\"]",
|
||||
filters: "{\"author\":\"jens\"}",
|
||||
}, inputSchema)).toEqual({
|
||||
arguments: {
|
||||
query: "pull request",
|
||||
limit: 4,
|
||||
score: 3.5,
|
||||
includeClosed: false,
|
||||
format: "full",
|
||||
labels: ["bug", "urgent"],
|
||||
filters: { author: "jens" },
|
||||
},
|
||||
errors: {},
|
||||
valid: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports missing required values, malformed JSON and type mismatches clearly", () => {
|
||||
const inputSchema = schema({
|
||||
count: { type: "integer" },
|
||||
enabled: { type: "boolean" },
|
||||
tags: { type: "array" },
|
||||
options: { type: "object" },
|
||||
}, ["count", "enabled", "tags", "options"]);
|
||||
|
||||
const parsed = parseSchemaArguments({ count: "2.3", enabled: "sometimes", tags: "{oops", options: "[]" }, inputSchema);
|
||||
|
||||
expect(parsed.valid).toBe(false);
|
||||
expect(parsed.arguments).toEqual({});
|
||||
expect(parsed.errors).toEqual({
|
||||
count: "Enter a whole number.",
|
||||
enabled: "Choose yes or no.",
|
||||
tags: "Enter valid JSON for this array.",
|
||||
options: "Enter a JSON object.",
|
||||
});
|
||||
});
|
||||
|
||||
it("applies string and numeric bounds and rejects enum values outside the schema", () => {
|
||||
const inputSchema = schema({
|
||||
query: { type: "string", minLength: 2, maxLength: 5 },
|
||||
limit: { type: "integer", minimum: 1, maximum: 8 },
|
||||
mode: { type: "string", enum: ["safe", "full"] },
|
||||
});
|
||||
|
||||
const parsed = parseSchemaArguments({ query: "x", limit: "9", mode: JSON.stringify("unsafe") }, inputSchema);
|
||||
|
||||
expect(parsed.valid).toBe(false);
|
||||
expect(parsed.errors.query).toBe("Enter at least 2 characters.");
|
||||
expect(parsed.errors.limit).toBe("Enter a number no greater than 8.");
|
||||
expect(parsed.errors.mode).toBe("Choose a value listed by this tool's schema.");
|
||||
});
|
||||
|
||||
it("omits empty optional fields and accepts explicit null only for nullable fields", () => {
|
||||
const inputSchema = schema({ optional: { type: "string" }, nullable: { type: ["string", "null"] } });
|
||||
|
||||
expect(parseSchemaArguments({ optional: "", nullable: NULL_INPUT_VALUE }, inputSchema)).toEqual({
|
||||
arguments: { nullable: null },
|
||||
errors: {},
|
||||
valid: true,
|
||||
});
|
||||
expect(parseSchemaArguments({ nullable: NULL_INPUT_VALUE }, schema({ nullable: { type: "string" } })).errors.nullable).toBe("This field does not accept null.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MAX_TOOL_RUNS, recordToolRun, readToolHistory, type ToolRunMetadata } from "../src/tool-history";
|
||||
|
||||
function createStorage(seed: string | null = null) {
|
||||
let value = seed;
|
||||
return {
|
||||
getItem: () => value,
|
||||
setItem: (_key: string, next: string) => { value = next; },
|
||||
value: () => value,
|
||||
};
|
||||
}
|
||||
|
||||
const run = (overrides: Partial<ToolRunMetadata> = {}): ToolRunMetadata => ({
|
||||
tool: "gitea.pullrequest.status",
|
||||
project: "ludarium",
|
||||
environment: "prod",
|
||||
ok: true,
|
||||
traceId: "trace-123",
|
||||
at: "2026-09-30T10:00:00.000Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("bounded recent tool history", () => {
|
||||
it("stores only safe invocation metadata and never serializes arguments or results", () => {
|
||||
const storage = createStorage();
|
||||
recordToolRun({ ...run(), arguments: { token: "fixture-do-not-store" }, result: "private output" } as ToolRunMetadata, storage);
|
||||
|
||||
expect(JSON.parse(storage.value() ?? "null")).toEqual([run()]);
|
||||
expect(storage.value()).not.toContain("fixture-do-not-store");
|
||||
expect(storage.value()).not.toContain("private output");
|
||||
});
|
||||
|
||||
it("bounds history to the newest twenty valid entries", () => {
|
||||
const storage = createStorage();
|
||||
for (let index = 0; index < MAX_TOOL_RUNS + 4; index += 1) {
|
||||
recordToolRun(run({ traceId: `trace-${index}`, at: `2026-09-30T10:${String(index).padStart(2, "0")}:00.000Z` }), storage);
|
||||
}
|
||||
|
||||
const history = readToolHistory(storage);
|
||||
expect(history).toHaveLength(MAX_TOOL_RUNS);
|
||||
expect(history[0]?.traceId).toBe(`trace-${MAX_TOOL_RUNS + 3}`);
|
||||
expect(history.at(-1)?.traceId).toBe("trace-4");
|
||||
});
|
||||
|
||||
it("drops malformed and oversized stored entries without exposing extra fields", () => {
|
||||
const storage = createStorage(JSON.stringify([
|
||||
{ ...run(), arguments: { apiKey: "secret" }, content: "result" },
|
||||
{ ...run(), traceId: "trace\nsecret" },
|
||||
{ ...run(), at: "yesterday" },
|
||||
null,
|
||||
]));
|
||||
|
||||
expect(readToolHistory(storage)).toEqual([run()]);
|
||||
});
|
||||
|
||||
it("treats corrupt or unavailable browser storage as an empty history", () => {
|
||||
expect(readToolHistory(createStorage("not-json"))).toEqual([]);
|
||||
expect(() => readToolHistory({ getItem: () => { throw new Error("blocked"); }, setItem: () => undefined })).not.toThrow();
|
||||
expect(() => recordToolRun(run(), { getItem: () => null, setItem: () => { throw new Error("blocked"); } })).not.toThrow();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user