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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions e2e/tests/agent-network-settings-shapes.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* Agent Network settings wire-shape spec.
*
* The management API signals "account not bootstrapped" differently by
* generation: current servers answer GET /agent-network/settings with the
* defaults object carrying an empty cluster/subdomain/endpoint, older ones
* with 200 + a JSON null body, and the oldest with a 404.
* useAgentNetworkSettings normalizes all three to the same null-settings
* signal, and this spec pins that: the providers page must render the
* connect-first endpoint placeholder for every unbootstrapped shape, and the
* endpoint badge once the account is bootstrapped. The settings route is
* mocked per test, so the assertions do not depend on the backend build or
* on account state left behind by other suites.
*/
import { test, expect, type Browser, type Page } from "@playwright/test";
import { loginToApp } from "../helpers/auth";

const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";

const EMPTY_STATE_TEXT =
"Connect your first provider to set up your agent network endpoint.";

const BOOTSTRAPPED_ENDPOINT = "violet.eu.proxy.netbird.io";

// The defaults object current servers return before the account is
// bootstrapped: values present, cluster/subdomain/endpoint empty, no
// timestamps (no row has been persisted yet).
const UNBOOTSTRAPPED_DEFAULTS = {
cluster: "",
subdomain: "",
endpoint: "",
enable_log_collection: true,
enable_prompt_collection: false,
redact_pii: false,
access_log_retention_days: 30,
};

const BOOTSTRAPPED_SETTINGS = {
cluster: "eu.proxy.netbird.io",
subdomain: "violet",
endpoint: BOOTSTRAPPED_ENDPOINT,
enable_log_collection: true,
enable_prompt_collection: false,
redact_pii: false,
access_log_retention_days: 30,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};

async function mockSettingsResponse(
page: Page,
response: { status: number; body: string },
) {
await page.route("**/api/agent-network/settings", (route) => {
if (route.request().method() !== "GET") return route.continue();
return route.fulfill({
status: response.status,
contentType: "application/json",
body: response.body,
});
});
}

async function openProvidersPage(
browser: Browser,
settingsResponse: { status: number; body: string },
): Promise<{ page: Page; close: () => Promise<void> }> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
// Close the context on any setup failure — the caller only receives the
// close callback once setup succeeds.
try {
await context.addInitScript(
([key, value]) => {
try {
window.localStorage.setItem(key as string, value as string);
} catch (e) {}
},
[AGENT_NETWORK_CONFIG_KEY, "enabled"],
);
const page = await context.newPage();
await mockSettingsResponse(page, settingsResponse);
await loginToApp(page, "owner");
await page.goto("/agent-network/providers");
await page.keyboard.press("Escape");
return { page, close: () => context.close() };
} catch (e) {
await context.close();
throw e;
}
}

const UNBOOTSTRAPPED_SHAPES: {
name: string;
response: { status: number; body: string };
}[] = [
{
name: "defaults object with empty endpoint (current servers)",
response: { status: 200, body: JSON.stringify(UNBOOTSTRAPPED_DEFAULTS) },
},
{
name: "200 with JSON null body (older servers)",
response: { status: 200, body: "null" },
},
{
name: "404 (oldest servers)",
response: {
status: 404,
body: JSON.stringify({ message: "settings not found", code: 404 }),
},
},
];

test.describe("Agent Network settings wire shapes @agent-network", () => {
for (const shape of UNBOOTSTRAPPED_SHAPES) {
test(`unbootstrapped account renders the empty state: ${shape.name}`, async ({
browser,
}) => {
const { page, close } = await openProvidersPage(browser, shape.response);
try {
await expect(page.getByText(EMPTY_STATE_TEXT)).toBeVisible();
await expect(page.getByText(BOOTSTRAPPED_ENDPOINT)).toHaveCount(0);
} finally {
await close();
}
});
}

test("bootstrapped account renders the endpoint instead of the empty state", async ({
browser,
}) => {
const { page, close } = await openProvidersPage(browser, {
status: 200,
body: JSON.stringify(BOOTSTRAPPED_SETTINGS),
});
try {
await expect(page.getByText(BOOTSTRAPPED_ENDPOINT)).toBeVisible();
await expect(page.getByText(EMPTY_STATE_TEXT)).toHaveCount(0);
} finally {
await close();
}
});
});
29 changes: 18 additions & 11 deletions src/modules/agent-network/AIProvidersProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,10 @@ export type APIAgentNetworkSettings = {
enable_prompt_collection: boolean;
redact_pii: boolean;
access_log_retention_days?: number;
created_at: string;
updated_at: string;
// Absent until the account is bootstrapped — pre-bootstrap the backend
// returns the defaults without a persisted row to date.
created_at?: string;
updated_at?: string;
};

// APIAgentNetworkSettingsRequest matches the PUT /agent-network/settings
Expand Down Expand Up @@ -521,10 +523,12 @@ export function useAIProviders() {
}

// useAgentNetworkSettings fetches the account-level agent-network settings.
// Returns null until the first provider is created — newer backends respond
// 200 + JSON null while no settings row exists; older backends respond 404,
// which we still tolerate via ignoreError so older deploys don't surface
// a spurious error in the empty state.
// Returns null until the account is bootstrapped (first provider create, or
// a settings update carrying a cluster). Backends signal the unbootstrapped
// state differently by age: current ones respond 200 with the defaults and
// an empty cluster/subdomain/endpoint, older ones 200 + JSON null, and the
// oldest 404 — tolerated via ignoreError so old deploys don't surface a
// spurious error in the empty state. All three normalize to null here.
export function useAgentNetworkSettings() {
const { enabled: agentNetworkEnabled } = useAgentNetworkMode();
const { data, error, isLoading, mutate } =
Expand All @@ -534,11 +538,14 @@ export function useAgentNetworkSettings() {
true,
agentNetworkEnabled,
);
const notFound = !!error && (error as { code?: number }).code === 404;
// SWR keeps the previous data alongside the error (keepPreviousData), so a
// later 404 must not expose the stale settings; other transient errors keep
// them, which is what keepPreviousData is for.
const settings = useMemo<AgentNetworkSettings | null>(
() => (data ? settingsFromAPI(data) : null),
[data],
() => (data && data.endpoint && !notFound ? settingsFromAPI(data) : null),
[data, notFound],
);
const notFound = !!error && (error as { code?: number }).code === 404;
return {
settings,
isLoading: isLoading && !notFound,
Expand Down Expand Up @@ -676,7 +683,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
updates.metadataDisabled ?? existing.metadata_disabled,
models: updates.models
? toAPIModels(updates.models)
: existing.models ?? [],
: (existing.models ?? []),
enabled: updates.enabled ?? existing.enabled,
};
try {
Expand Down Expand Up @@ -761,7 +768,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
guardrail_ids: updates.guardrailIds ?? existing.guardrail_ids ?? [],
limits: updates.limits
? policyLimitsToAPI(updates.limits)
: existing.limits ?? policyLimitsToAPI(EMPTY_POLICY_LIMITS),
: (existing.limits ?? policyLimitsToAPI(EMPTY_POLICY_LIMITS)),
};
try {
await policiesApi.put(merged, `/${id}`);
Expand Down
Loading