Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ split oversized components before extending them. Use `internal/obs/log` for log

Run `make check` before completion. The standalone gate includes all daemon/shared
Go tests, Core contract/client/service tests, Core Web and TypeScript client
checks (including fixture-only Playwright acceptance), a real dedicated PostgreSQL test
checks (including production-console Playwright acceptance with a synthetic Core upstream), a real dedicated PostgreSQL test
database, byte-for-byte sqlc regeneration checks, standalone API builds, Claude SDK
tests and packaging, MiniMax companion checks, and Rust filesystem-helper
tests/format/Clippy. It intentionally has no product Web/server/installer gates. The full gate fails when the database variable is missing. The test database role
Expand Down
39 changes: 39 additions & 0 deletions apps/web/e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Administrator console acceptance

Run from the repository root with the pinned Node, pnpm and Go versions:

```sh
pnpm test:web:acceptance
```

Playwright starts a synthetic Core upstream and builds/runs the production
`services/core-console` binary with freshly built Web assets. Login, cookies,
origin checks, the route allowlist and credential forwarding are production code.
The fixture accepts only the test deployment credential and never handles
`/console/auth` or manufactures browser sessions. Other than the sign-in test,
browser contexts reuse a cookie obtained from real login so the suite respects
the production login rate limit. Its data and writes are
synthetic; this suite is not live cluster or model execution acceptance.

`GO` may select an absolute Go executable. Set `AGENTS_FIXTURE_PORT` (default
18611) and `AGENTS_WEB_PORT` (default 19619) to unused loopback ports for concurrent
runs. Existing services are never reused. Account state, the private test token,
installer stub, binary and assets live in a fresh `~/.parsar/tests/console-e2e-*`
directory, removed on shutdown. Browser artifacts go to
`~/.parsar/tests/console-playwright`; `AGENTS_E2E_OUTPUT_DIR` selects an independent
output directory. Chrome is the default Playwright browser; set
`AGENTS_E2E_BROWSER_CHANNEL=chromium` to use the installed bundled Chromium.

The installer stub exercises command creation and the production configuration's
digest only. Tests never execute an enrollment command or install a node.

The suite retains current management behavior: login, Projects/keys, one-time
secrets, uncertain writes, copy/delete, monitoring, read-only Session history,
node management and failure feedback. The previous builder, execution playground,
resource editors and browser connection-key flows were retired with those UI
features. Public Agents API compatibility remains in the Core/client tests.

Project isolation, shared application-key access, revocation enforcement, archive
retention, copy transactions, deletion preconditions and audit persistence require
Core backend tests and real deployment acceptance. Fixture responses cannot prove
these invariants. The repository's required `make check` still applies.
7 changes: 4 additions & 3 deletions apps/web/e2e/access.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { expect, test } from "@playwright/test";

import { expectManagementBoundary, resetFixture } from "./console";
import { expectManagementBoundary, observeBrowser, resetFixture } from "./console";

test.afterEach(async ({ request }) => expectManagementBoundary(request));
test.afterEach(async ({ request, page }) => expectManagementBoundary(request, page));

test("creates the administrator, keeps no credential in the browser, and signs out and back in", async ({ page, request }) => {
await resetFixture(request, "setup");
await resetFixture(request);
observeBrowser(page);
await page.addInitScript(() => window.localStorage.setItem("agents-core-web.language", "en"));
await page.goto("/");

Expand Down
61 changes: 44 additions & 17 deletions apps/web/e2e/console.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,60 @@
import { expect, type APIRequestContext, type Page } from "@playwright/test";
import { expect, type APIRequestContext, type BrowserContext, type Page } from "@playwright/test";

const fixture = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`;
const web = `http://127.0.0.1:${process.env.AGENTS_WEB_PORT ?? 4174}`;
export const fixture = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18611}`;
export const web = `http://127.0.0.1:${process.env.AGENTS_WEB_PORT ?? 19619}`;
const browserRequests = new WeakMap<Page, Array<{ path: string; authorization: string | undefined }>>();
export function observeBrowser(page: Page) {
const calls: Array<{ path: string; authorization: string | undefined }> = [];
browserRequests.set(page, calls);
page.on("request", request => {
const path = new URL(request.url()).pathname;
if (path.startsWith("/core/") || path.startsWith("/v1")) calls.push({ path, authorization: request.headers().authorization });
});
}
let authenticatedCookies: Awaited<ReturnType<BrowserContext["cookies"]>> = [];
export const account = { username: "admin", password: "correct horse battery" };

/** Fresh fixture state: "setup" (no administrator yet), "login" or "authenticated". */
export async function resetFixture(request: APIRequestContext, auth: "setup" | "login" | "authenticated" = "authenticated") {
await request.post(`${fixture}/__fixture/reset?auth=${auth}`);
/** Reset only synthetic Core data; account state belongs to the real console. */
export async function resetFixture(request: APIRequestContext) {
expect((await request.post(`${fixture}/__fixture/reset`)).ok()).toBe(true);
}

/** Opens the console already signed in, in English. */
/** Authenticate using the production account endpoint and its real session cookie. */
export async function openConsole(page: Page, request: APIRequestContext, hash = "overview") {
await resetFixture(request, "authenticated");
await page.context().addCookies([{ name: "core_console", value: "fixture-session", url: web }]);
await resetFixture(request);
observeBrowser(page);
// Reuse a session issued by the real service, avoiding its login rate limit.
// Browser contexts and synthetic Core data are still fresh for each test.
await page.context().addCookies(authenticatedCookies);
const status = await (await page.request.get("/console/auth")).json();
if (status.mode !== "authenticated") {
const response = await page.request.post(`/console/auth/${status.mode === "setup" ? "setup" : "login"}`, { headers: { Origin: web }, data: account });
expect(response.status()).toBe(200);
authenticatedCookies = await page.context().cookies();
}
await page.addInitScript(() => window.localStorage.setItem("agents-core-web.language", "en"));
await page.goto(`/#${hash}`);
}

/** Makes the next matching write fail once with the given status. */
export async function failNext(request: APIRequestContext, failure: { method: string; path: string; status: number; code?: string; message?: string }) {
await request.post(`${fixture}/__fixture/fail-next`, { data: failure });
/** Inject a Core response, keeping browser and proxy behavior real. */
export async function failNext(request: APIRequestContext, failure: { method: string; path: string; status: number; code?: string; message?: string; repeat?: boolean }) {
expect((await request.post(`${fixture}/__fixture/fail-next`, { data: failure })).ok()).toBe(true);
}

/** Writes the browser sent through the console, as "METHOD /path". */
export async function requests(request: APIRequestContext) {
return (await (await request.get(`${fixture}/__fixture/requests`)).json());
}
export async function writes(request: APIRequestContext): Promise<string[]> {
return (await (await request.get(`${fixture}/__fixture/requests`)).json()).writes;
return (await requests(request)).writes;
}

/** The console never calls /v1 and never sends its own Authorization header. */
export async function expectManagementBoundary(request: APIRequestContext) {
const { violations } = await (await request.get(`${fixture}/__fixture/requests`)).json();
/** Every Core request comes from the authenticated console, without browser cookies. */
export async function expectManagementBoundary(request: APIRequestContext, page: Page) {
for (const call of browserRequests.get(page) ?? []) {
expect(call.path).not.toMatch(/^\/v1(?:\/|$)/);
expect(call.authorization).toBeUndefined();
}
const { violations, calls } = await requests(request);
expect(violations).toEqual([]);
for (const call of calls) expect(call).toMatchObject({ authenticated: true, actor: account.username, cookie: null });
}
73 changes: 25 additions & 48 deletions apps/web/e2e/fixture-console.mjs → apps/web/e2e/fixture-core.mjs
Original file line number Diff line number Diff line change
@@ -1,27 +1,23 @@
// Browser acceptance fixture: the console service's routes (/console/**) and the
// management surfaces it forwards (/core/v1/admin/**, /core/v1/sandbox/**), with
// synthetic, deterministic data and in-memory writes. It never serves /v1; any
// /v1 request, and any browser-supplied Authorization header, is recorded so a
// test can assert that the console stays on its management boundary.
// Synthetic Core upstream behind the real production core-console service.
// No browser authentication or proxy logic belongs in this fixture.
import http from "node:http";

import { buildAdmin } from "./data/admin.mjs";
import { coreMetrics } from "./data/core-metrics.mjs";
import { buildResources } from "./data/resources.mjs";
import { buildDemo } from "./data/routes.mjs";

const port = Number(process.env.AGENTS_FIXTURE_PORT ?? 18092);
const SESSION_COOKIE = "core_console=fixture-session";
const port = Number(process.env.AGENTS_FIXTURE_PORT ?? 18611);
import { ADMIN_TOKEN } from "./fixture-settings.mjs";

let state;
function reset(mode = "setup") {
function reset() {
const base = buildDemo();
const now = Math.floor(Date.now() / 1000);
const resources = buildResources(now, base.agents, base.sessions);
state = {
...base, resources, admin: buildAdmin(now, base, resources),
auth: { mode, username: mode === "authenticated" ? "admin" : null, password: mode === "setup" ? null : "correct horse battery" },
violations: [], writes: [], failNext: null, nextId: 1,
violations: [], calls: [], metricsUnknown: false, writes: [], failNext: null, nextId: 1,
};
}
reset();
Expand Down Expand Up @@ -61,35 +57,6 @@ const startupConfiguration = {
},
};

async function consoleRoute(request, response, url) {
const auth = state.auth;
if (url.pathname === "/console/auth" && request.method === "GET") {
if (auth.mode === "authenticated" && request.headers.cookie?.includes(SESSION_COOKIE)) return send(response, 200, { mode: "authenticated", username: auth.username });
return send(response, 200, { mode: auth.mode === "setup" ? "setup" : "login" });
}
if (url.pathname === "/console/auth/setup" && request.method === "POST") {
if (auth.mode !== "setup") return error(response, 409, "Setup is complete.");
const { username, password } = await body(request);
if (!username || !password || password.length < 12) return error(response, 400, "Invalid administrator.");
Object.assign(auth, { mode: "authenticated", username, password });
return send(response, 200, { mode: "authenticated", username }, { "set-cookie": `${SESSION_COOKIE}; Path=/; HttpOnly; SameSite=Strict` });
}
if (url.pathname === "/console/auth/login" && request.method === "POST") {
const { username, password } = await body(request);
if (username !== auth.username || password !== auth.password) return error(response, 401, "Sign-in failed.");
auth.mode = "authenticated";
return send(response, 200, { mode: "authenticated", username }, { "set-cookie": `${SESSION_COOKIE}; Path=/; HttpOnly; SameSite=Strict` });
}
if (url.pathname === "/console/auth/logout" && request.method === "POST") {
auth.mode = "login";
return send(response, 200, { mode: "login" }, { "set-cookie": `${SESSION_COOKIE.split("=")[0]}=; Path=/; Max-Age=0` });
}
if (url.pathname === "/console/config") {
return send(response, 200, { api_keys: true, sandbox_admin: true, node_installer: true, node_installer_sha256: "a".repeat(64) });
}
return error(response, 404, "Not found.");
}

async function adminWrite(request, response, path) {
const a = state.admin;
const input = request.method === "POST" ? await body(request) : {};
Expand All @@ -111,6 +78,7 @@ async function adminWrite(request, response, path) {
const project = match && a.projects.find((entry) => entry.id === match[1]);
if (!project) return error(response, 404, "No such project.");
const rest = match[2] ?? "";
if (!rest && request.method === "POST") { project.name = input.name; return send(response, 200, a.publicProject(project)); }
if (rest === "/archive" && request.method === "POST") {
const at = Math.floor(Date.now() / 1000);
project.archived_at = at;
Expand Down Expand Up @@ -149,7 +117,15 @@ function adminRead(response, path, url) {
return send(response, 200, { object: "list", data, has_more: false, first_id: data[0]?.observation.id ?? null, last_id: data.at(-1)?.observation.id ?? null });
}
if (path === "/startup-configuration") return send(response, 200, startupConfiguration);
if (path === "/core-metrics") return send(response, 200, coreMetrics(url.searchParams.get("range") ?? "1h"));
if (path === "/core-metrics") {
const value = coreMetrics(url.searchParams.get("range") ?? "1h");
if (state.metricsUnknown) {
for (const key of ["slots_in_use", "slots_total", "queued_turns", "connected_daemons"]) value.execution[key] = null;
value.database.ping_ms = { p50: null, p95: null };
value.process.memory_bytes = null;
}
return send(response, 200, value);
}
const match = path.match(/^\/projects\/([^/]+)(\/.*)?$/);
const project = match && a.projects.find((entry) => entry.id === match[1]);
if (!project) return error(response, 404, "No such project.");
Expand Down Expand Up @@ -208,14 +184,15 @@ async function sandboxRoute(request, response, path) {
async function fixtureRoute(request, response, url) {
if (url.pathname === "/__fixture/health") return send(response, 200, { ok: true });
if (url.pathname === "/__fixture/reset" && request.method === "POST") {
reset(url.searchParams.get("auth") ?? "setup");
reset();
return send(response, 200, { ok: true });
}
if (url.pathname === "/__fixture/fail-next" && request.method === "POST") {
state.failNext = await body(request); // { method, path, status, code?, message? }
return send(response, 200, { ok: true });
}
if (url.pathname === "/__fixture/requests") return send(response, 200, { violations: state.violations, writes: state.writes });
if (url.pathname === "/__fixture/metrics-unknown" && request.method === "POST") { state.metricsUnknown = true; return send(response, 200, {}); }
if (url.pathname === "/__fixture/requests") return send(response, 200, { violations: state.violations, writes: state.writes, calls: state.calls });
return error(response, 404, "Not found.");
}

Expand All @@ -227,15 +204,15 @@ http.createServer(async (request, response) => {
state.violations.push(`${request.method} ${url.pathname}`);
return error(response, 404, "The console does not serve /v1.");
}
if (request.headers.authorization) state.violations.push(`Authorization header on ${request.method} ${url.pathname}`);
if (url.pathname.startsWith("/console/")) return await consoleRoute(request, response, url);
const signedIn = state.auth.mode === "authenticated" && request.headers.cookie?.includes(SESSION_COOKIE);
if (!signedIn) return error(response, 401, "Sign in to the console.");
const call = { method: request.method, path: url.pathname, actor: request.headers["x-core-console-actor"] ?? null, authenticated: request.headers.authorization === `Bearer ${ADMIN_TOKEN}`, cookie: request.headers.cookie ?? null };
state.calls.push(call);
if (!call.authenticated) { state.violations.push(`Missing deployment credential on ${request.method} ${url.pathname}`); return error(response, 401, "Deployment authentication required."); }
if (call.cookie !== null) state.violations.push("Browser cookie reached Core");
const write = request.method !== "GET" && request.method !== "HEAD";
if (write) state.writes.push(`${request.method} ${url.pathname}`);
const fail = state.failNext;
if (fail && fail.method === request.method && url.pathname.includes(fail.path)) {
state.failNext = null;
if (!fail.repeat) state.failNext = null;
return error(response, fail.status, fail.message ?? "Injected failure.", fail.code ?? null);
}
if (url.pathname.startsWith("/core/v1/admin/")) {
Expand All @@ -247,5 +224,5 @@ http.createServer(async (request, response) => {
} catch (caught) {
error(response, 500, String(caught));
}
}).listen(port, "127.0.0.1", () => console.log(`Console acceptance fixture on http://127.0.0.1:${port}`));
}).listen(port, "127.0.0.1", () => console.log(`Core upstream fixture on http://127.0.0.1:${port}`));

2 changes: 2 additions & 0 deletions apps/web/e2e/fixture-settings.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Test-only credential. The launcher puts it in a private file for core-console.
export const ADMIN_TOKEN = 'acceptance-deployment-admin-only';
18 changes: 16 additions & 2 deletions apps/web/e2e/monitoring.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { expect, test } from "@playwright/test";

import { expectManagementBoundary, openConsole } from "./console";
import { expectManagementBoundary, failNext, openConsole } from "./console";

test.afterEach(async ({ request }) => expectManagementBoundary(request));
test.afterEach(async ({ request, page }) => expectManagementBoundary(request, page));

test("shows the deployment's health on Overview and each monitor page", async ({ page, request }) => {
await openConsole(page, request, "overview");
Expand All @@ -29,3 +29,17 @@ test("opens a Session's conversation from the Session log, read-only", async ({
await expect(page.locator(".chat-row.user").first()).toBeVisible();
await expect(page.getByRole("textbox")).toHaveCount(0);
});

test('Core metrics keep missing measurements unknown and make a refresh failure visible', async ({ page, request }) => {
await openConsole(page, request, 'core-metrics');
await expect(page.getByLabel('Core summary')).toBeVisible();
await request.post(`http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18611}/__fixture/metrics-unknown`);
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
const values = page.getByLabel('Core summary').locator('.kpi-value');
await expect(values).toHaveCount(5);
for (const value of await values.all()) await expect(value).toHaveText('—');
await failNext(request, { method: 'GET', path: '/core-metrics', status: 503, repeat: true });
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
await expect(page.getByRole('alert')).toContainText('Refresh failed');
for (const value of await values.all()) await expect(value).toHaveText('—');
});
14 changes: 12 additions & 2 deletions apps/web/e2e/nodes.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { expect, test } from "@playwright/test";

import { expectManagementBoundary, openConsole } from "./console";
import { expectManagementBoundary, failNext, openConsole } from "./console";

test.afterEach(async ({ request }) => expectManagementBoundary(request));
test.afterEach(async ({ request, page }) => expectManagementBoundary(request, page));

test("prepares a one-time node command and removes a node after confirmation", async ({ page, request }) => {
await openConsole(page, request, "nodes");
Expand All @@ -17,3 +17,13 @@ test("prepares a one-time node command and removes a node after confirmation", a
await expect(confirm).toBeHidden();
await expect(page.getByRole("table", { name: "Sandbox nodes" })).not.toContainText("edge-03");
});


test("failed node reads cannot present the cached hosts as healthy", async ({ page, request }) => {
await openConsole(page, request, "nodes");
await expect(page.getByRole("table", { name: "Sandbox nodes" })).toContainText("edge-03");
await failNext(request, { method: "GET", path: "/nodes", status: 503, repeat: true });
await page.reload();
await expect(page.getByRole("alert").first()).toBeVisible();
await expect(page.getByRole("table", { name: "Sandbox nodes" })).toHaveCount(0);
});
Loading