mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49582fff74 | ||
|
|
af7745a03b | ||
|
|
19d82cc2ab | ||
|
|
b66cc4ccb7 | ||
|
|
1d408f2e7e | ||
|
|
9fab9d7a83 | ||
|
|
6ca91287fc | ||
|
|
5a5e04ad35 | ||
|
|
598ecb196d | ||
|
|
7c8352822d | ||
|
|
e4f5046ff2 | ||
|
|
d5f61efc1a | ||
|
|
3503141c56 | ||
|
|
a27e4654cf | ||
|
|
b9569f48d5 | ||
|
|
9694bb753d | ||
|
|
377d239a01 | ||
|
|
a266fff89d | ||
|
|
8ed69f21c5 | ||
|
|
58d1035b16 | ||
|
|
991234785c | ||
|
|
f2ad889fdc | ||
|
|
1f219c4ffc | ||
|
|
87b3a08adc | ||
|
|
cfd0037cef | ||
|
|
ac7d578bd7 |
No files matched your search
@@ -39,7 +39,7 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: netbirdio/IronRDP
|
||||
latest: true
|
||||
tag: v0.0.2
|
||||
fileName: "*.ts"
|
||||
out-file-path: 'public/ironrdp-pkg'
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: netbirdio/IronRDP
|
||||
latest: true
|
||||
tag: v0.0.2
|
||||
fileName: "*.js"
|
||||
out-file-path: 'public/ironrdp-pkg'
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: netbirdio/IronRDP
|
||||
latest: true
|
||||
tag: v0.0.2
|
||||
fileName: "ironrdp_web_bg.wasm"
|
||||
out-file-path: 'public/ironrdp-pkg'
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Sync deploy templates to S3
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "templates/reverse-proxy/**"
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
role-to-assume: ${{ secrets.DEPLOY_TEMPLATES_ROLE_ARN }}
|
||||
aws-region: eu-central-1
|
||||
|
||||
- name: Validate CloudFormation template
|
||||
run: |
|
||||
aws cloudformation validate-template \
|
||||
--template-body file://templates/reverse-proxy/netbird-proxy-cfn.yaml
|
||||
|
||||
- name: Upload CloudFormation template
|
||||
run: |
|
||||
aws s3 cp templates/reverse-proxy/netbird-proxy-cfn.yaml \
|
||||
s3://netbird-deploy-templates/templates/netbird-proxy-cfn.yaml
|
||||
@@ -0,0 +1,78 @@
|
||||
name: Test cloud deploy config
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "docker/init_react_envs.sh"
|
||||
- "templates/reverse-proxy/**"
|
||||
- "src/modules/reverse-proxy/clusters/ClusterCloudDeploy.tsx"
|
||||
- ".github/workflows/test-cloud-deploy.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
csp-connect-src:
|
||||
name: CSP allows cloud provider APIs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install nginx
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y nginx gettext-base
|
||||
|
||||
- name: Prepare nginx layout expected by the init script
|
||||
run: |
|
||||
sudo mkdir -p /etc/nginx/http.d /usr/share/nginx/html
|
||||
echo 'add_header Content-Security-Policy "placeholder" always;' | sudo tee /etc/nginx/http.d/default.conf
|
||||
sudo touch /usr/share/nginx/html/OidcTrustedDomains.js.tmpl
|
||||
sudo systemctl start nginx
|
||||
|
||||
- name: Run init script with production-like env
|
||||
env:
|
||||
AUTH_AUTHORITY: https://auth.example.com
|
||||
AUTH_CLIENT_ID: test-client
|
||||
AUTH_AUDIENCE: test-audience
|
||||
AUTH_SUPPORTED_SCOPES: openid profile email
|
||||
USE_AUTH0: "false"
|
||||
NETBIRD_MGMT_API_ENDPOINT: https://api.example.com
|
||||
run: sudo -E bash docker/init_react_envs.sh
|
||||
|
||||
- name: Assert provider APIs are in connect-src
|
||||
run: |
|
||||
CSP=$(grep -o 'Content-Security-Policy "[^"]*"' /etc/nginx/http.d/default.conf)
|
||||
echo "Generated CSP: $CSP"
|
||||
CONNECT_SRC=$(echo "$CSP" | sed 's/.*connect-src \([^;]*\);.*/\1/')
|
||||
echo "connect-src: $CONNECT_SRC"
|
||||
status=0
|
||||
for origin in https://api.hetzner.cloud https://api.digitalocean.com; do
|
||||
if echo "$CONNECT_SRC" | grep -qF "$origin"; then
|
||||
echo "OK: $origin"
|
||||
else
|
||||
echo "MISSING in connect-src: $origin"
|
||||
status=1
|
||||
fi
|
||||
done
|
||||
exit $status
|
||||
|
||||
cfn-template-url:
|
||||
name: CloudFormation template URL is live
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Extract template URL from component and check it
|
||||
run: |
|
||||
URL=$(grep -o 'https://[^"]*netbird-proxy-cfn\.yaml' src/modules/reverse-proxy/clusters/ClusterCloudDeploy.tsx)
|
||||
echo "CFN_TEMPLATE_URL: $URL"
|
||||
test -n "$URL"
|
||||
# Non-blocking: the template is published by sync-deploy-templates on
|
||||
# the default branch, so it may not exist yet for a PR that only edits
|
||||
# the URL. Warn instead of failing the check.
|
||||
if curl -sfI "$URL" >/dev/null; then
|
||||
echo "OK: template URL is live"
|
||||
else
|
||||
echo "::warning::CloudFormation template URL is not reachable yet: $URL"
|
||||
fi
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
[
|
||||
{
|
||||
"tag": "Preview",
|
||||
"text": "The new NetBird desktop app is here - now available as a 0.75 release candidate.",
|
||||
"link": "https://github.com/netbirdio/netbird/discussions/6483",
|
||||
"tag": "New",
|
||||
"text": "The new NetBird desktop app is here - now available in the v0.75 release.",
|
||||
"link": "https://netbird.io/knowledge-hub/netbird-v0-75-new-desktop-app",
|
||||
"linkText": "Learn more",
|
||||
"variant": "important",
|
||||
"isExternal": true,
|
||||
|
||||
@@ -80,7 +80,7 @@ echo "NetBird latest version: ${NETBIRD_LATEST_VERSION}"
|
||||
FIRST_PARTY_CSP="pkgs.netbird.io"
|
||||
FIRST_PARTY_CSP_CONNECT_SRC="wss://*.netbird.io"
|
||||
THIRD_PARTY_CSP="*.licdn.com *.linkedin.com *.vector.co *.sibforms.com *.hotjar.com *.hotjar.io *.redditstatic.com pixel-config.reddit.com *.clarity.ms c.bing.com *.microsoft.com googleads.g.doubleclick.net pagead2.googlesyndication.com www.google.com www.googleadservices.com *.google-analytics.com *.googletagmanager.com analytics.google.com *.hubapi.com *.hs-banner.com *.hubspot.com *.hubspot.net js.hs-analytics.com *.hsforms.net *.hscollectedforms.net *.hs-analytics.net *.hsforms.com track.hubspot.com *.hsadspixel.net static.hsappstatic.net"
|
||||
THIRD_PARTY_CSP_CONNECT_SRC="https://api.github.com/repos/netbirdio/netbird/releases/latest https://raw.githubusercontent.com/netbirdio/dashboard/ wss://ws.hotjar.com"
|
||||
THIRD_PARTY_CSP_CONNECT_SRC="https://api.github.com/repos/netbirdio/netbird/releases/latest https://raw.githubusercontent.com/netbirdio/dashboard/ wss://ws.hotjar.com https://api.hetzner.cloud https://api.digitalocean.com"
|
||||
THIRD_PARTY_CSP_SCRIPT_SRC="'sha256-7knV6EIjKUvCpYWE2rCYx8dYV2WCNb2bpTuitFXzBcA=' *.hs-scripts.com"
|
||||
|
||||
CSP_DOMAINS=""
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -218,7 +218,6 @@ await visitByNavigation(page, "Policies"); // Click child
|
||||
| Notifications | `settings-notifications-email.spec.ts`, `settings-notifications-slack.spec.ts`, `settings-notifications-webhook.spec.ts` | `@notifications` |
|
||||
| Team | `team-users.spec.ts`, `team-service-users.spec.ts`, `team-users-approval-and-billing.spec.ts` | `@team` |
|
||||
| Setup Keys | `setup-keys.spec.ts` | `@setup-keys` |
|
||||
| Control Center | `control-center-draft.spec.ts`, `control-center-draft-connections.spec.ts`, `control-center-draft-matrix.spec.ts` | `@control-center` |
|
||||
|
||||
## Debugging
|
||||
|
||||
|
||||
+50
-14
@@ -89,25 +89,11 @@ async function apiDelete(page: Page, path: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function apiPost<T>(page: Page, path: string, data: unknown): Promise<T> {
|
||||
const { token, origin } = await getApiContext(page);
|
||||
const resp = await page.request.post(`${origin}/api${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data,
|
||||
});
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
/** List all groups. */
|
||||
export async function listGroups(page: Page): Promise<Group[]> {
|
||||
return apiGet<Group[]>(page, "/groups");
|
||||
}
|
||||
|
||||
/** Create a group directly via the API (test setup). */
|
||||
export async function createGroup(page: Page, name: string): Promise<Group> {
|
||||
return apiPost<Group>(page, "/groups", { name, peers: [] });
|
||||
}
|
||||
|
||||
/** Delete a group by ID. */
|
||||
export async function deleteGroup(page: Page, groupId: string) {
|
||||
await apiDelete(page, `/groups/${groupId}`);
|
||||
@@ -447,3 +433,53 @@ export async function deleteUserByEmail(page: Page, email: string) {
|
||||
await deleteUserById(page, user.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent Network
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type AgentNetworkCatalogProvider = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type AgentNetworkProvider = {
|
||||
id: string;
|
||||
name: string;
|
||||
provider_id: string;
|
||||
};
|
||||
|
||||
/** List the Agent Network provider catalog (server-defined provider types). */
|
||||
export async function listAgentNetworkCatalog(
|
||||
page: Page,
|
||||
): Promise<AgentNetworkCatalogProvider[]> {
|
||||
return apiGet<AgentNetworkCatalogProvider[]>(
|
||||
page,
|
||||
"/agent-network/catalog/providers",
|
||||
);
|
||||
}
|
||||
|
||||
/** List connected Agent Network providers. */
|
||||
export async function listAgentNetworkProviders(
|
||||
page: Page,
|
||||
): Promise<AgentNetworkProvider[]> {
|
||||
return apiGet<AgentNetworkProvider[]>(page, "/agent-network/providers");
|
||||
}
|
||||
|
||||
/** Delete an Agent Network provider by ID. */
|
||||
export async function deleteAgentNetworkProviderById(page: Page, id: string) {
|
||||
await apiDelete(page, `/agent-network/providers/${id}`);
|
||||
}
|
||||
|
||||
/** Delete all Agent Network providers whose name starts with the prefix. */
|
||||
export async function deleteAgentNetworkProvidersByPrefix(
|
||||
page: Page,
|
||||
prefix: string,
|
||||
) {
|
||||
const providers = await listAgentNetworkProviders(page);
|
||||
for (const p of providers) {
|
||||
if (p.name.startsWith(prefix)) {
|
||||
await deleteAgentNetworkProviderById(page, p.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-4
@@ -20,7 +20,11 @@ const credentials: Record<TestUser, { username: string; password: string }> = {
|
||||
/**
|
||||
* Navigate to the app, authenticate via Zitadel, and wait for the app to load.
|
||||
*/
|
||||
export async function loginToApp(page: Page, user: TestUser = "owner") {
|
||||
export async function loginToApp(
|
||||
page: Page,
|
||||
user: TestUser = "owner",
|
||||
opts: { expectOnboarding?: boolean } = {},
|
||||
) {
|
||||
const { username, password } = credentials[user];
|
||||
|
||||
await page.goto("/");
|
||||
@@ -39,7 +43,9 @@ export async function loginToApp(page: Page, user: TestUser = "owner") {
|
||||
const which = await Promise.race([
|
||||
appReady.waitFor({ timeout: 20_000 }).then(() => "app" as const),
|
||||
setupModal.waitFor({ timeout: 20_000 }).then(() => "modal" as const),
|
||||
approvalPending.waitFor({ timeout: 20_000 }).then(() => "approval" as const),
|
||||
approvalPending
|
||||
.waitFor({ timeout: 20_000 })
|
||||
.then(() => "approval" as const),
|
||||
onboarding.waitFor({ timeout: 20_000 }).then(() => "onboarding" as const),
|
||||
selectAccount.waitFor({ timeout: 20_000 }).then(() => "select" as const),
|
||||
loginInput.waitFor({ timeout: 20_000 }).then(() => "login" as const),
|
||||
@@ -51,6 +57,9 @@ export async function loginToApp(page: Page, user: TestUser = "owner") {
|
||||
}
|
||||
|
||||
if (which === "modal") {
|
||||
// Onboarding tests render a full-screen overlay over the setup modal;
|
||||
// clicking modal-close would be pointer-intercepted, so leave it be.
|
||||
if (opts.expectOnboarding) return;
|
||||
await setupModal.getByTestId("modal-close").click();
|
||||
await expect(setupModal).not.toBeVisible();
|
||||
return;
|
||||
@@ -89,8 +98,12 @@ export async function loginToApp(page: Page, user: TestUser = "owner") {
|
||||
onboarding.waitFor({ timeout: 15_000 }),
|
||||
]);
|
||||
|
||||
// Dismiss setup modal if present
|
||||
if (await setupModal.isVisible().catch(() => false)) {
|
||||
// Dismiss setup modal if present, unless an onboarding overlay is expected
|
||||
// on top of it (clicking through it would be pointer-intercepted).
|
||||
if (
|
||||
!opts.expectOnboarding &&
|
||||
(await setupModal.isVisible().catch(() => false))
|
||||
) {
|
||||
await setupModal.getByTestId("modal-close").click();
|
||||
await expect(setupModal).not.toBeVisible();
|
||||
}
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
import { Page, Locator, expect } from "@playwright/test";
|
||||
import { navigateTo } from "./auth";
|
||||
|
||||
export const CHANGES_KEY = "netbird-control-center-draft-changes";
|
||||
export const CANVAS_KEY = "netbird-control-center-draft-canvas";
|
||||
|
||||
export async function openControlCenter(page: Page) {
|
||||
await navigateTo(page, "/control-center");
|
||||
// The canvas hides behind cc-prefit until the first fitView; wait for the
|
||||
// pane itself so later mouse coordinates are meaningful.
|
||||
await expect(page.locator(".react-flow__pane")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function enterDraft(page: Page) {
|
||||
await page.getByTestId("cc-mode-draft").click();
|
||||
// The toolbar slides in with a spring animation — waiting for the Add
|
||||
// button also guarantees draft mode is fully active.
|
||||
await expect(page.getByTestId("cc-toolbar-add")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function exitDraftDiscarding(page: Page) {
|
||||
const draftTab = page.getByTestId("cc-mode-draft");
|
||||
if ((await draftTab.getAttribute("data-state")) !== "active") return;
|
||||
await page.getByTestId("cc-mode-live").click();
|
||||
const confirm = page.getByTestId("confirmation.confirm");
|
||||
if (await confirm.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
await confirm.click();
|
||||
}
|
||||
await expect(page.getByTestId("cc-toolbar-add")).not.toBeVisible();
|
||||
}
|
||||
|
||||
/** Clears draft localStorage and makes sure we're on a clean live canvas. */
|
||||
export async function resetDraftState(page: Page) {
|
||||
await openControlCenter(page);
|
||||
await page.evaluate(
|
||||
([changes, canvas]) => {
|
||||
localStorage.removeItem(changes);
|
||||
localStorage.removeItem(canvas);
|
||||
},
|
||||
[CHANGES_KEY, CANVAS_KEY],
|
||||
);
|
||||
await exitDraftDiscarding(page);
|
||||
}
|
||||
|
||||
export async function readDraftChanges(page: Page): Promise<any[]> {
|
||||
const raw = await page.evaluate(
|
||||
(key) => localStorage.getItem(key),
|
||||
CHANGES_KEY,
|
||||
);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
}
|
||||
|
||||
async function centerOf(locator: Locator) {
|
||||
const box = await locator.boundingBox();
|
||||
if (!box) throw new Error("element has no layout");
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||
}
|
||||
|
||||
async function mouseDrag(
|
||||
page: Page,
|
||||
from: { x: number; y: number },
|
||||
to: { x: number; y: number },
|
||||
) {
|
||||
await page.mouse.move(from.x, from.y);
|
||||
await page.mouse.down();
|
||||
// Intermediate moves so drag thresholds and drop-target detection see a
|
||||
// realistic pointer path.
|
||||
const steps = 12;
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
await page.mouse.move(
|
||||
from.x + ((to.x - from.x) * i) / steps,
|
||||
from.y + ((to.y - from.y) * i) / steps,
|
||||
);
|
||||
}
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drags a components-panel template onto the canvas. The panel uses a custom
|
||||
* pointer-based drag (not HTML5 DnD), so raw mouse events are required.
|
||||
*/
|
||||
export async function dragTemplateToCanvas(
|
||||
page: Page,
|
||||
templateTestId: string,
|
||||
target?: { x: number; y: number },
|
||||
) {
|
||||
const item = page.getByTestId(templateTestId);
|
||||
if (!(await item.isVisible().catch(() => false))) {
|
||||
await page.getByTestId("cc-toolbar-add").click();
|
||||
await expect(item).toBeVisible();
|
||||
}
|
||||
const pane = page.locator(".react-flow__pane");
|
||||
const paneBox = await pane.boundingBox();
|
||||
if (!paneBox) throw new Error("canvas pane not laid out");
|
||||
const to = target ?? {
|
||||
// Land right of the panel, roughly canvas center.
|
||||
x: paneBox.x + paneBox.width * 0.6,
|
||||
y: paneBox.y + paneBox.height * 0.5,
|
||||
};
|
||||
await mouseDrag(page, await centerOf(item), to);
|
||||
// Close the panel so it doesn't cover nodes for the next interaction.
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
export function canvasNode(page: Page, dataIdPrefix: string) {
|
||||
return page.locator(`.react-flow__node[data-id^="${dataIdPrefix}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects two canvas nodes by dragging from a connect handle onto the target
|
||||
* node (FullAreaTargetHandle spans the whole node). Handle side matters:
|
||||
* dragging from `sr` (right) puts the source node on a policy's SOURCE side,
|
||||
* from `sl` (left) on the DESTINATION side. Resources only have `sl`.
|
||||
*/
|
||||
export async function connectNodes(
|
||||
page: Page,
|
||||
source: Locator,
|
||||
target: Locator,
|
||||
handleSide: "sr" | "sl" = "sr",
|
||||
) {
|
||||
// Connect handles fade in on node hover.
|
||||
await source.hover();
|
||||
const handle = source.locator(
|
||||
`.react-flow__handle[data-handleid="${handleSide}-connect"]`,
|
||||
);
|
||||
await expect(handle).toBeAttached();
|
||||
await mouseDrag(page, await centerOf(handle), await centerOf(target));
|
||||
}
|
||||
|
||||
/** Positions relative to the canvas pane (fractions of width/height). */
|
||||
export async function panePoint(page: Page, fx: number, fy: number) {
|
||||
const pane = await page.locator(".react-flow__pane").boundingBox();
|
||||
if (!pane) throw new Error("canvas pane not laid out");
|
||||
return { x: pane.x + pane.width * fx, y: pane.y + pane.height * fy };
|
||||
}
|
||||
|
||||
/** Right-clicks empty canvas and clicks a creation item ("New Group", …). */
|
||||
export async function createViaCanvasMenu(
|
||||
page: Page,
|
||||
itemText: string,
|
||||
at?: { fx: number; fy: number },
|
||||
) {
|
||||
const point = await panePoint(page, at?.fx ?? 0.6, at?.fy ?? 0.5);
|
||||
await page.mouse.move(point.x, point.y);
|
||||
await page.mouse.click(point.x, point.y, { button: "right" });
|
||||
const menu = page.getByTestId("cc-canvas-context-menu");
|
||||
await expect(menu).toBeVisible();
|
||||
await menu.getByRole("button", { name: itemText }).click();
|
||||
}
|
||||
|
||||
export async function readDraftCanvas(page: Page): Promise<any | null> {
|
||||
const raw = await page.evaluate(
|
||||
(key) => localStorage.getItem(key),
|
||||
CANVAS_KEY,
|
||||
);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
}
|
||||
|
||||
/** Drags one canvas node onto another (e.g. a peer into a group). */
|
||||
export async function dragNodeOnto(page: Page, node: Locator, target: Locator) {
|
||||
await mouseDrag(page, await centerOf(node), await centerOf(target));
|
||||
}
|
||||
|
||||
/** Right-clicks a node and clicks the given context-menu item. */
|
||||
export async function clickContextMenuItem(
|
||||
page: Page,
|
||||
node: Locator,
|
||||
itemText: string,
|
||||
) {
|
||||
await node.click({ button: "right" });
|
||||
const menu = page.getByTestId("cc-node-context-menu");
|
||||
await expect(menu).toBeVisible();
|
||||
await menu.getByRole("button", { name: itemText, exact: true }).click();
|
||||
}
|
||||
|
||||
export function reviewButton(page: Page) {
|
||||
return page.getByTestId("cc-draft-review");
|
||||
}
|
||||
|
||||
export async function expectChangeCount(page: Page, count: number) {
|
||||
if (count === 0) {
|
||||
await expect(reviewButton(page)).toBeDisabled();
|
||||
} else {
|
||||
await expect(reviewButton(page)).toBeEnabled();
|
||||
await expect(reviewButton(page)).toContainText(String(count));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the create-policy modal wizard (policy → posture checks → general)
|
||||
* and submits. Assumes both sides are already filled in.
|
||||
*/
|
||||
export async function submitCreatePolicyModal(page: Page, name?: string) {
|
||||
await page.getByTestId("policy-continue").click();
|
||||
await page.getByTestId("policy-continue").click();
|
||||
if (name) {
|
||||
await page.getByTestId("policy-name").fill(name);
|
||||
}
|
||||
await page.getByTestId("submit-policy").click();
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Agent Network prompt-cache accounting spec.
|
||||
*
|
||||
* Drives the Usage & Logs page against mocked agent-network endpoints whose
|
||||
* rows carry the cache fields (cached_input_tokens / cache_creation_tokens /
|
||||
* cache_cost_usd) plus the per-bucket cost breakdown, and verifies the hover
|
||||
* breakdowns: the access-log Tokens tooltip gains cache read/write rows and a
|
||||
* cache-aware total, the Cost tooltip lists one line per billed bucket, and
|
||||
* the usage overview's daily table
|
||||
* surfaces the day's cache buckets. Route mocks keep the spec hermetic, so it
|
||||
* passes against management builds that predate the cache fields.
|
||||
*/
|
||||
import { test, expect, type Browser, type Page } from "@playwright/test";
|
||||
import { loginToApp, navigateTo } from "../helpers/auth";
|
||||
|
||||
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
|
||||
|
||||
// The reported field scenario: a session-start request that wrote a ~32k-token
|
||||
// prompt cache, and a follow-up that read it back.
|
||||
const writeEntry = {
|
||||
id: "anlog-cache-write",
|
||||
service_id: "svc-e2e",
|
||||
timestamp: new Date().toISOString(),
|
||||
status_code: 200,
|
||||
duration_ms: 29000,
|
||||
input_tokens: 10,
|
||||
output_tokens: 1472,
|
||||
total_tokens: 33591,
|
||||
cached_input_tokens: 0,
|
||||
cache_creation_tokens: 32109,
|
||||
cost_usd: 0.142519,
|
||||
cache_cost_usd: 0.120409,
|
||||
input_cost_usd: 0.00003,
|
||||
cached_input_cost_usd: 0,
|
||||
cache_creation_cost_usd: 0.120409,
|
||||
output_cost_usd: 0.02208,
|
||||
provider: "bedrock",
|
||||
model: "anthropic.claude-sonnet-4-5",
|
||||
session_id: "sess-cache-write",
|
||||
decision: "allow",
|
||||
stream: false,
|
||||
};
|
||||
|
||||
const readEntry = {
|
||||
...writeEntry,
|
||||
id: "anlog-cache-read",
|
||||
output_tokens: 800,
|
||||
total_tokens: 32919,
|
||||
cached_input_tokens: 32109,
|
||||
cache_creation_tokens: 0,
|
||||
cost_usd: 0.021663,
|
||||
cache_cost_usd: 0.009633,
|
||||
input_cost_usd: 0.00003,
|
||||
cached_input_cost_usd: 0.009633,
|
||||
cache_creation_cost_usd: 0,
|
||||
output_cost_usd: 0.012,
|
||||
session_id: "sess-cache-read",
|
||||
};
|
||||
|
||||
const todayBucket = {
|
||||
period_start: new Date().toISOString().slice(0, 10),
|
||||
input_tokens: 20,
|
||||
output_tokens: 2272,
|
||||
total_tokens: 66510,
|
||||
cached_input_tokens: 32109,
|
||||
cache_creation_tokens: 32109,
|
||||
cost_usd: 0.164182,
|
||||
cache_cost_usd: 0.130042,
|
||||
input_cost_usd: 0.00006,
|
||||
cached_input_cost_usd: 0.009633,
|
||||
cache_creation_cost_usd: 0.120409,
|
||||
output_cost_usd: 0.03408,
|
||||
};
|
||||
|
||||
async function mockCacheUsage(page: Page) {
|
||||
await page.route("**/api/agent-network/access-logs*", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
data: [writeEntry, readEntry],
|
||||
page: 1,
|
||||
page_size: 25,
|
||||
total_records: 2,
|
||||
total_pages: 1,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/agent-network/usage/overview*", (route) =>
|
||||
route.fulfill({ json: [todayBucket] }),
|
||||
);
|
||||
}
|
||||
|
||||
async function newUsagePage(browser: Browser): Promise<{
|
||||
page: Page;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const context = await browser.newContext({
|
||||
storageState: "e2e/fixtures/auth/owner.json",
|
||||
});
|
||||
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 mockCacheUsage(page);
|
||||
await loginToApp(page, "owner");
|
||||
return { page, close: () => context.close() };
|
||||
}
|
||||
|
||||
// FullTooltip force-mounts its content, so a tooltip keeps its role=tooltip
|
||||
// element in the DOM after closing — a bare getByRole("tooltip") accumulates
|
||||
// every tooltip the test has ever opened. Radix puts role=tooltip on a
|
||||
// visually-hidden span and the open/closed flag on that span's parent
|
||||
// ("delayed-open"/"instant-open" vs "closed"), so match the parent that is
|
||||
// currently open. Its text covers both the visible copy and the hidden one.
|
||||
function tooltip(page: Page) {
|
||||
return page.locator(
|
||||
'[data-state="delayed-open"]:has(> [role="tooltip"]), [data-state="instant-open"]:has(> [role="tooltip"])',
|
||||
);
|
||||
}
|
||||
|
||||
// Radix's hoverable content holds a tooltip open while the pointer travels
|
||||
// toward it, so moving straight to the next cell can leave two open at once.
|
||||
// Escape dismisses the open one deterministically, wherever the pointer sits.
|
||||
async function closeTooltip(page: Page) {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(tooltip(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
test.describe.serial("Agent Network cache accounting @agent-network", () => {
|
||||
test("access-log hover breaks out prompt-cache tokens and cost", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await newUsagePage(browser);
|
||||
try {
|
||||
await navigateTo(page, "/agent-network/usage?tab=access-logs");
|
||||
|
||||
const writeRow = page.getByRole("row").filter({ hasText: "$0.1425" });
|
||||
await expect(writeRow).toBeVisible();
|
||||
|
||||
// Tokens tooltip: cache-write bucket plus a total that includes it.
|
||||
await writeRow.getByText("1,472").hover();
|
||||
await expect(tooltip(page)).toContainText("32,109");
|
||||
await expect(tooltip(page)).toContainText("cache write");
|
||||
await expect(tooltip(page)).toContainText("33,591");
|
||||
|
||||
// Cost tooltip: all four buckets are listed plus the total, including
|
||||
// buckets that cost nothing — this request wrote the cache but never read
|
||||
// it, so "cache read" is present with a $0.0000 amount.
|
||||
await closeTooltip(page);
|
||||
await writeRow.getByText("$0.1425").hover();
|
||||
await expect(tooltip(page)).toContainText("input");
|
||||
await expect(tooltip(page)).toContainText("cache read");
|
||||
await expect(tooltip(page)).toContainText("cache write");
|
||||
await expect(tooltip(page)).toContainText("output");
|
||||
await expect(tooltip(page)).toContainText("$0.1204");
|
||||
await expect(tooltip(page)).toContainText("$0.0221");
|
||||
await expect(tooltip(page)).toContainText("$0.0000");
|
||||
await expect(tooltip(page)).toContainText("total");
|
||||
|
||||
// The follow-up request reads the cache back: read bucket filled, write
|
||||
// bucket still listed as a zero row.
|
||||
// No exact match: the output count renders behind an sr-only "Output:" prefix.
|
||||
const readRow = page.getByRole("row").filter({ hasText: "$0.0217" });
|
||||
await closeTooltip(page);
|
||||
await readRow.getByText("800").hover();
|
||||
await expect(tooltip(page)).toContainText("cache read");
|
||||
await expect(tooltip(page)).toContainText("32,919");
|
||||
await expect(tooltip(page)).toContainText(/0\s*cache write/);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("usage overview daily table surfaces the day's cache buckets", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await newUsagePage(browser);
|
||||
try {
|
||||
await navigateTo(page, "/agent-network/usage");
|
||||
|
||||
// Row-scoped on purpose: the hovers now repeat the cell's own figure on
|
||||
// their total row, and FullTooltip force-mounts that content, so a
|
||||
// page-wide getByText would match the cell and the tooltip both. Radix
|
||||
// portals tooltip content to <body>, outside the row.
|
||||
const dayRow = page.getByRole("row").filter({ hasText: "$0.16" });
|
||||
|
||||
// Total Tokens includes the additive cache buckets; hover splits them out
|
||||
// in the same value-then-label rows the access-log table uses.
|
||||
await expect(dayRow.getByText("66,510")).toBeVisible();
|
||||
await dayRow.getByText("66,510").hover();
|
||||
await expect(tooltip(page)).toContainText(/20\s*input/);
|
||||
await expect(tooltip(page)).toContainText(/2,272\s*output/);
|
||||
await expect(tooltip(page)).toContainText(/32,109\s*cache read/);
|
||||
await expect(tooltip(page)).toContainText(/32,109\s*cache write/);
|
||||
await expect(tooltip(page)).toContainText(/66,510\s*total/);
|
||||
|
||||
// Cost hover breaks the day into the same four billed buckets.
|
||||
await closeTooltip(page);
|
||||
await dayRow.getByText("$0.16", { exact: true }).hover();
|
||||
await expect(tooltip(page)).toContainText(/\$0\.0001\s*input/);
|
||||
await expect(tooltip(page)).toContainText(/\$0\.0341\s*output/);
|
||||
await expect(tooltip(page)).toContainText(/\$0\.0096\s*cache read/);
|
||||
await expect(tooltip(page)).toContainText(/\$0\.1204\s*cache write/);
|
||||
await expect(tooltip(page)).toContainText(/\$0\.1642\s*total/);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* Agent Network focused-view spec.
|
||||
*
|
||||
* Exercises the account-driven focused view added for netbird.ai signups:
|
||||
* the `agent_network_only` account setting hides the rest of the dashboard,
|
||||
* an explicit `false` opts back out, and a pending netbird.ai signup applies
|
||||
* the focused view optimistically and persists the setting via PUT /accounts.
|
||||
*
|
||||
* The test management backend does not know the setting, so GET /accounts is
|
||||
* rewritten per-test to inject it (same interception approach as
|
||||
* edition-gating.spec.ts). "only" mode is asserted through the Networks nav
|
||||
* item, which toggles purely on the focused-view flag and does not depend on
|
||||
* premium permission modules.
|
||||
*/
|
||||
import { test, expect, type Browser, type Page } from "@playwright/test";
|
||||
import { loginToApp, navigateTo } from "../helpers/auth";
|
||||
|
||||
const SIGNUP_SOURCE_KEY = "netbird-signup-source";
|
||||
const AGENT_NETWORK_SOURCE = "netbird.ai";
|
||||
// Drives the NETBIRD_AGENT_NETWORK_ONLY / _ENABLED deployment flags against the
|
||||
// test build (see testAgentNetworkOverride in utils/netbird.ts).
|
||||
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
|
||||
|
||||
type AgentNetworkConfig = "only" | "enabled" | "off";
|
||||
|
||||
type AccountMock = {
|
||||
// undefined leaves the setting absent (as an un-onboarded account would be).
|
||||
agentNetworkOnly?: boolean;
|
||||
signupFormPending?: boolean;
|
||||
// dashboardFeaturesAgentNetwork sets settings.dashboard_features.agent_network,
|
||||
// which makes the Agent Network menu available without focusing the dashboard.
|
||||
dashboardFeaturesAgentNetwork?: boolean;
|
||||
};
|
||||
|
||||
// mockAccounts rewrites GET /accounts to inject the focused-view setting and
|
||||
// onboarding state onto the real account. A PUT to /accounts/{id} is captured
|
||||
// and flips the injected setting to true, mirroring the server persisting the
|
||||
// value so the optimistic view does not revert once the write settles.
|
||||
function mockAccounts(
|
||||
page: Page,
|
||||
initial: AccountMock,
|
||||
captured: { putBody?: any },
|
||||
) {
|
||||
let applied = initial.agentNetworkOnly;
|
||||
|
||||
page.route("**/api/accounts", async (route) => {
|
||||
if (route.request().method() !== "GET") return route.continue();
|
||||
const response = await route.fetch();
|
||||
let body: any;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch (e) {
|
||||
return route.fulfill({ response });
|
||||
}
|
||||
if (Array.isArray(body) && body[0]) {
|
||||
body[0].settings = { ...(body[0].settings ?? {}) };
|
||||
if (applied === undefined) {
|
||||
delete body[0].settings.agent_network_only;
|
||||
} else {
|
||||
body[0].settings.agent_network_only = applied;
|
||||
}
|
||||
if (initial.dashboardFeaturesAgentNetwork !== undefined) {
|
||||
body[0].settings.dashboard_features = {
|
||||
agent_network: initial.dashboardFeaturesAgentNetwork,
|
||||
};
|
||||
}
|
||||
body[0].onboarding = {
|
||||
...(body[0].onboarding ?? {}),
|
||||
signup_form_pending: !!initial.signupFormPending,
|
||||
};
|
||||
}
|
||||
return route.fulfill({ response, json: body });
|
||||
});
|
||||
|
||||
page.route("**/api/accounts/*", async (route) => {
|
||||
if (route.request().method() !== "PUT") return route.continue();
|
||||
try {
|
||||
captured.putBody = route.request().postDataJSON();
|
||||
} catch (e) {
|
||||
captured.putBody = null;
|
||||
}
|
||||
applied = true;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ settings: { agent_network_only: true } }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openWithAccount(
|
||||
browser: Browser,
|
||||
account: AccountMock,
|
||||
opts: {
|
||||
signupSource?: boolean;
|
||||
agentNetworkConfig?: AgentNetworkConfig;
|
||||
} = {},
|
||||
): Promise<{
|
||||
page: Page;
|
||||
captured: { putBody?: any };
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const context = await browser.newContext({
|
||||
storageState: "e2e/fixtures/auth/owner.json",
|
||||
});
|
||||
if (opts.signupSource) {
|
||||
await context.addInitScript(
|
||||
([key, value]) => {
|
||||
try {
|
||||
window.localStorage.setItem(key as string, value as string);
|
||||
} catch (e) {}
|
||||
},
|
||||
[SIGNUP_SOURCE_KEY, AGENT_NETWORK_SOURCE],
|
||||
);
|
||||
}
|
||||
if (opts.agentNetworkConfig) {
|
||||
await context.addInitScript(
|
||||
([key, value]) => {
|
||||
try {
|
||||
window.localStorage.setItem(key as string, value as string);
|
||||
} catch (e) {}
|
||||
},
|
||||
[AGENT_NETWORK_CONFIG_KEY, opts.agentNetworkConfig],
|
||||
);
|
||||
}
|
||||
const page = await context.newPage();
|
||||
const captured: { putBody?: any } = {};
|
||||
mockAccounts(page, account, captured);
|
||||
await loginToApp(page, "owner");
|
||||
return { page, captured, close: () => context.close() };
|
||||
}
|
||||
|
||||
function navItem(page: Page, text: string) {
|
||||
return page
|
||||
.getByTestId("left-navigation-item")
|
||||
.getByText(text, { exact: true });
|
||||
}
|
||||
|
||||
// navItemContaining matches a sidebar entry by a substring of its label. Some
|
||||
// entries (Agent Network, Reverse Proxy) render a "Beta" badge inside the label
|
||||
// when the dashboard is not focused, so exact text matching would miss them.
|
||||
function navItemContaining(page: Page, text: string) {
|
||||
return page.getByTestId("left-navigation-item").filter({ hasText: text });
|
||||
}
|
||||
|
||||
// Regular dashboard sections that the focused view hides (gated on
|
||||
// !agentNetworkOnly in Navigation.tsx).
|
||||
const FOCUS_HIDDEN_NAV = ["Networks", "Reverse Proxy", "DNS", "Activity"];
|
||||
// Agent Network view routes that make up the focused surface.
|
||||
const AGENT_VIEW_PATHS = [
|
||||
"/agent-network/providers",
|
||||
"/agent-network/policies",
|
||||
"/agent-network/usage",
|
||||
"/agent-network/configuration",
|
||||
];
|
||||
|
||||
test.describe.serial("Agent Network focused view @agent-network", () => {
|
||||
test("focused menu shows only Agent Network views and hides the regular sections", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openWithAccount(browser, {
|
||||
agentNetworkOnly: true,
|
||||
});
|
||||
try {
|
||||
// Peers always renders for an owner and is not part of the focused
|
||||
// split, so it confirms the sidebar rendered before asserting absences.
|
||||
await expect(navItem(page, "Peers")).toBeVisible();
|
||||
|
||||
// The Agent Network section is present and the regular sections are gone.
|
||||
await expect(navItem(page, "Agent Network")).toBeVisible();
|
||||
for (const label of FOCUS_HIDDEN_NAV) {
|
||||
await expect(navItem(page, label)).toHaveCount(0);
|
||||
}
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("focused view keeps every Agent Network route reachable", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openWithAccount(browser, {
|
||||
agentNetworkOnly: true,
|
||||
});
|
||||
try {
|
||||
// Each Agent Network view renders instead of the route guard redirecting
|
||||
// away; the Agent Network section stays in the sidebar throughout.
|
||||
for (const path of AGENT_VIEW_PATHS) {
|
||||
await navigateTo(page, path);
|
||||
await expect(page).toHaveURL(new RegExp(path.replace(/\//g, "\\/")));
|
||||
await expect(navItem(page, "Agent Network")).toBeVisible();
|
||||
}
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("explicit opt-out restores the regular menu and removes Agent Network", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openWithAccount(browser, {
|
||||
agentNetworkOnly: false,
|
||||
});
|
||||
try {
|
||||
// The sidebar rendered (Peers is always present for an owner)...
|
||||
await expect(navItem(page, "Peers")).toBeVisible();
|
||||
// ...and the Agent Network section is gone: the explicit opt-out wins
|
||||
// and, without a deployment config flag, the section is not enabled.
|
||||
await expect(navItem(page, "Agent Network")).toHaveCount(0);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("dashboard_features.agent_network makes the menu available without focusing", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openWithAccount(browser, {
|
||||
dashboardFeaturesAgentNetwork: true,
|
||||
});
|
||||
try {
|
||||
// The Agent Network menu is available (the label carries a "Beta" badge
|
||||
// outside focused mode, so match on the label substring)...
|
||||
await expect(navItemContaining(page, "Agent Network")).toBeVisible();
|
||||
// ...but the dashboard is not focused: a regular section that focused
|
||||
// mode hides (Reverse Proxy) is still present.
|
||||
await expect(navItemContaining(page, "Reverse Proxy")).toBeVisible();
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("applies the focused view optimistically for a pending netbird.ai signup and persists it", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, captured, close } = await openWithAccount(
|
||||
browser,
|
||||
{ agentNetworkOnly: undefined, signupFormPending: true },
|
||||
{ signupSource: true },
|
||||
);
|
||||
try {
|
||||
// Focused view is applied immediately, before the setting is persisted.
|
||||
await expect(navItem(page, "Agent Network")).toBeVisible();
|
||||
await expect(navItem(page, "Networks")).toHaveCount(0);
|
||||
|
||||
// The signup source is persisted: focused view plus the Agent Network
|
||||
// menu flag, so turning focus off later keeps access to Agent Network.
|
||||
await expect
|
||||
.poll(() => captured.putBody?.settings?.agent_network_only)
|
||||
.toBe(true);
|
||||
expect(
|
||||
captured.putBody?.settings?.dashboard_features?.agent_network,
|
||||
).toBe(true);
|
||||
|
||||
// The focused view is retained after the write settles.
|
||||
await expect(navItem(page, "Networks")).toHaveCount(0);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("existing netbird.ai account gets dashboard_features, not the focused view", async ({
|
||||
browser,
|
||||
}) => {
|
||||
// An existing account (signup form already submitted) with the netbird.ai
|
||||
// source should have the Agent Network menu made available via
|
||||
// dashboard_features, not the focused agent_network_only view.
|
||||
const { page, captured, close } = await openWithAccount(
|
||||
browser,
|
||||
{ signupFormPending: false },
|
||||
{ signupSource: true },
|
||||
);
|
||||
try {
|
||||
await expect
|
||||
.poll(
|
||||
() => captured.putBody?.settings?.dashboard_features?.agent_network,
|
||||
)
|
||||
.toBe(true);
|
||||
expect(captured.putBody?.settings?.agent_network_only).not.toBe(true);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
// Matrix of the two deployment flags (NETBIRD_AGENT_NETWORK_ONLY / _ENABLED,
|
||||
// "off" = neither) crossed with the netbird.ai signup source, with no explicit
|
||||
// account settings. The API defaults agent_network_only to false, so the
|
||||
// deployment flags must still take effect. A present source implies a new
|
||||
// signup (signup_form_pending), which focuses the dashboard optimistically
|
||||
// regardless of the flags.
|
||||
type Expectation = "hidden" | "available" | "focused";
|
||||
|
||||
const ENV_SIGNUP_MATRIX: {
|
||||
env: AgentNetworkConfig;
|
||||
signupSource: boolean;
|
||||
expected: Expectation;
|
||||
}[] = [
|
||||
{ env: "off", signupSource: false, expected: "hidden" },
|
||||
{ env: "off", signupSource: true, expected: "focused" },
|
||||
{ env: "enabled", signupSource: false, expected: "available" },
|
||||
{ env: "enabled", signupSource: true, expected: "focused" },
|
||||
{ env: "only", signupSource: false, expected: "focused" },
|
||||
{ env: "only", signupSource: true, expected: "focused" },
|
||||
];
|
||||
|
||||
async function expectAgentNetworkState(page: Page, expected: Expectation) {
|
||||
// Peers always renders for an owner, confirming the sidebar loaded.
|
||||
await expect(navItem(page, "Peers")).toBeVisible();
|
||||
|
||||
if (expected === "hidden") {
|
||||
await expect(navItemContaining(page, "Agent Network")).toHaveCount(0);
|
||||
// The regular dashboard stays intact.
|
||||
await expect(navItemContaining(page, "Reverse Proxy")).toBeVisible();
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(navItemContaining(page, "Agent Network")).toBeVisible();
|
||||
|
||||
if (expected === "focused") {
|
||||
for (const label of FOCUS_HIDDEN_NAV) {
|
||||
await expect(navItem(page, label)).toHaveCount(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// available: the menu shows alongside the regular dashboard.
|
||||
await expect(navItemContaining(page, "Reverse Proxy")).toBeVisible();
|
||||
}
|
||||
|
||||
for (const { env, signupSource, expected } of ENV_SIGNUP_MATRIX) {
|
||||
const sourceLabel = signupSource ? "netbird.ai" : "none";
|
||||
test(`env=${env} signup_source=${sourceLabel} -> ${expected}`, async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openWithAccount(
|
||||
browser,
|
||||
// A source present means a fresh netbird.ai signup (form still pending);
|
||||
// otherwise a plain account with no agent-network settings.
|
||||
{ signupFormPending: signupSource },
|
||||
{ agentNetworkConfig: env, signupSource },
|
||||
);
|
||||
try {
|
||||
await expectAgentNetworkState(page, expected);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("exposes the focused-view toggle in client settings", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openWithAccount(browser, {
|
||||
agentNetworkOnly: true,
|
||||
});
|
||||
try {
|
||||
await navigateTo(page, "/settings?tab=clients");
|
||||
await expect(page.getByTestId("agent-network-only")).toBeVisible();
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Agent Network Kimi (Moonshot AI) provider spec.
|
||||
*
|
||||
* Walks the Kimi provider lifecycle end to end against the real backend:
|
||||
* pick kimi_api from the catalog (prefilled host, catalog models with
|
||||
* pricing), connect it, then verify the Kimi-gated config surfaces in the
|
||||
* "Configure Your Agent" modal (Kimi CLI tab, Kimi backend option in the
|
||||
* Claude Code tab) that only render when a Kimi provider is connected.
|
||||
*
|
||||
* The kimi_api catalog entry ships with newer management builds. When the
|
||||
* backend under test predates it, the whole suite skips instead of failing —
|
||||
* same trade-off as the provider matrix in netbird's agent-network e2e
|
||||
* harness, which skips per-provider on missing credentials.
|
||||
*
|
||||
* The Agent Network menu is deployment-gated; the test build honors the
|
||||
* localStorage override (see testAgentNetworkOverride in utils/netbird.ts),
|
||||
* set via addInitScript on a dedicated context below.
|
||||
*/
|
||||
import { test, expect, type Browser, type Page } from "@playwright/test";
|
||||
import { loginToApp } from "../helpers/auth";
|
||||
import { generateRandomName } from "../helpers/utils";
|
||||
import {
|
||||
deleteAgentNetworkProvidersByPrefix,
|
||||
listAgentNetworkCatalog,
|
||||
} from "../helpers/api";
|
||||
|
||||
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
|
||||
const KIMI_CATALOG_ID = "kimi_api";
|
||||
const KIMI_CATALOG_NAME = "Kimi (Moonshot AI) API";
|
||||
const PROVIDER_PREFIX = "e2e-kimi-";
|
||||
|
||||
async function newAgentNetworkPage(browser: Browser): Promise<{
|
||||
page: Page;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const context = await browser.newContext({
|
||||
storageState: "e2e/fixtures/auth/owner.json",
|
||||
});
|
||||
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 loginToApp(page, "owner");
|
||||
return { page, close: () => context.close() };
|
||||
}
|
||||
|
||||
test.describe.serial("Agent Network Kimi provider @agent-network", () => {
|
||||
test("connect a Kimi provider and see Kimi agent configs", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await newAgentNetworkPage(browser);
|
||||
try {
|
||||
// Backend support probe: skip the suite on management builds whose
|
||||
// catalog predates kimi_api.
|
||||
const catalog = await listAgentNetworkCatalog(page);
|
||||
test.skip(
|
||||
!catalog.some((c) => c.id === KIMI_CATALOG_ID),
|
||||
`management catalog has no ${KIMI_CATALOG_ID} entry`,
|
||||
);
|
||||
|
||||
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
|
||||
|
||||
await page.goto("/agent-network/providers");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// ---- connect the provider ----
|
||||
await page
|
||||
.getByRole("button", { name: "Connect Provider" })
|
||||
.first()
|
||||
.click({ force: true });
|
||||
|
||||
// The provider select defaults to OpenAI API; search the catalog for
|
||||
// the Kimi entry.
|
||||
await page
|
||||
.getByRole("button", { name: /OpenAI API/ })
|
||||
.first()
|
||||
.click({ force: true });
|
||||
await page.getByPlaceholder("Search providers...").fill("kimi");
|
||||
await page.getByText(KIMI_CATALOG_NAME).first().click({ force: true });
|
||||
|
||||
// Catalog default host lands in the upstream URL input.
|
||||
await expect(
|
||||
page.locator('input[value="https://api.moonshot.ai"]'),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByPlaceholder("sk-...")
|
||||
.first()
|
||||
.fill("sk-e2e-kimi-test-key");
|
||||
|
||||
const providerName = generateRandomName(PROVIDER_PREFIX);
|
||||
const nameInput = page.locator(`input[value="${KIMI_CATALOG_NAME}"]`);
|
||||
await nameInput.fill(providerName);
|
||||
|
||||
// ---- models tab: catalog models carry pricing ----
|
||||
await page.getByRole("tab", { name: "Models" }).click({ force: true });
|
||||
await page.getByRole("button", { name: "Add More" }).click({ force: true });
|
||||
await page.getByText("Kimi K3", { exact: false }).first().click({ force: true });
|
||||
// Catalog price for kimi-k3 input tokens pre-fills the price cell.
|
||||
await expect(page.locator('input[value="0.003"]')).toBeVisible();
|
||||
|
||||
const createResponse = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/agent-network/providers") &&
|
||||
resp.request().method() === "POST",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page
|
||||
.getByRole("button", { name: "Connect Provider" })
|
||||
.last()
|
||||
.click({ force: true });
|
||||
expect([200, 201]).toContain((await createResponse).status());
|
||||
|
||||
// Row lands in the providers table.
|
||||
await expect(page.getByText(providerName).first()).toBeVisible();
|
||||
|
||||
// ---- Kimi-gated agent config surfaces ----
|
||||
await page
|
||||
.getByRole("button", { name: "Agent Config" })
|
||||
.click({ force: true });
|
||||
|
||||
// Kimi CLI tab only renders when a kimi_api provider is connected.
|
||||
await expect(page.getByRole("tab", { name: "Kimi CLI" })).toBeVisible();
|
||||
|
||||
// Claude Code tab's backend dropdown offers (and, with Kimi as the only
|
||||
// Anthropic-shaped provider, pre-selects) Kimi — its settings.json
|
||||
// snippet pins every model slot to kimi-k3 and disables tool search,
|
||||
// per Moonshot's Claude Code guide.
|
||||
await expect(page.getByText('"ANTHROPIC_MODEL": "kimi-k3"')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('"CLAUDE_CODE_SUBAGENT_MODEL": "kimi-k3"'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('"ENABLE_TOOL_SEARCH": "false"'),
|
||||
).toBeVisible();
|
||||
// The base URL carries the /anthropic shape prefix that rides through
|
||||
// the endpoint to Moonshot's Anthropic surface.
|
||||
await expect(
|
||||
page.getByText(/"ANTHROPIC_BASE_URL": "https:\/\/[^"]+\/anthropic"/),
|
||||
).toBeVisible();
|
||||
|
||||
// Kimi CLI tab carries the ~/.kimi/config.toml provider block.
|
||||
await page.getByRole("tab", { name: "Kimi CLI" }).click({ force: true });
|
||||
await expect(page.getByText('default_model = "kimi-k3"')).toBeVisible();
|
||||
|
||||
// Codex has no Kimi variant — Kimi's upstream doesn't support Codex, so
|
||||
// the tab keeps the plain Responses-API config with no backend dropdown.
|
||||
await page.getByRole("tab", { name: "Codex" }).click({ force: true });
|
||||
await expect(page.getByText('wire_api = "responses"')).toBeVisible();
|
||||
await expect(page.getByText('wire_api = "chat"')).not.toBeVisible();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// ---- cleanup ----
|
||||
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,353 +0,0 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "../helpers/fixtures";
|
||||
import { createGroup, deleteGroup, listGroups } from "../helpers/api";
|
||||
import { generateRandomName } from "../helpers/utils";
|
||||
import {
|
||||
canvasNode,
|
||||
clickContextMenuItem,
|
||||
connectNodes,
|
||||
dragNodeOnto,
|
||||
dragTemplateToCanvas,
|
||||
enterDraft,
|
||||
expectChangeCount,
|
||||
readDraftChanges,
|
||||
resetDraftState,
|
||||
reviewButton,
|
||||
submitCreatePolicyModal,
|
||||
} from "../helpers/control-center";
|
||||
|
||||
test.describe.serial(
|
||||
"Control Center Draft Connections @control-center",
|
||||
() => {
|
||||
test.beforeEach(async ({ dashboardAsOwner: page }) => {
|
||||
await resetDraftState(page);
|
||||
});
|
||||
|
||||
test("Should create a policy by connecting two groups", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
// Two groups, placed apart so the connect drag has a clear path.
|
||||
const pane = await page.locator(".react-flow__pane").boundingBox();
|
||||
if (!pane) throw new Error("no pane");
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.45,
|
||||
y: pane.y + pane.height * 0.35,
|
||||
});
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.75,
|
||||
y: pane.y + pane.height * 0.65,
|
||||
});
|
||||
const groups = canvasNode(page, "group-new-");
|
||||
await expect(groups).toHaveCount(2);
|
||||
await expectChangeCount(page, 2);
|
||||
|
||||
// Drag from the first group's right handle onto the second group —
|
||||
// the create-policy modal opens prefilled with both sides.
|
||||
await connectNodes(page, groups.nth(0), groups.nth(1));
|
||||
await expect(page.getByTestId("policy-name")).toBeVisible();
|
||||
await submitCreatePolicyModal(page);
|
||||
|
||||
// A complete policy lands on canvas with two edges and a tracked
|
||||
// create-policy change (2 groups + 1 policy = 3).
|
||||
await expect(canvasNode(page, "policy-new-")).toHaveCount(1);
|
||||
await expect(page.locator(".react-flow__edge")).toHaveCount(2);
|
||||
await expectChangeCount(page, 3);
|
||||
const changes = await readDraftChanges(page);
|
||||
expect(changes.filter((c) => c.type === "create-policy")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("Should keep a placeholder-peer policy out of the changeset until the peer installs", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
const pane = await page.locator(".react-flow__pane").boundingBox();
|
||||
if (!pane) throw new Error("no pane");
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.45,
|
||||
y: pane.y + pane.height * 0.35,
|
||||
});
|
||||
await dragTemplateToCanvas(page, "cc-template-peer-user-device", {
|
||||
x: pane.x + pane.width * 0.75,
|
||||
y: pane.y + pane.height * 0.65,
|
||||
});
|
||||
const group = canvasNode(page, "group-new-").first();
|
||||
const peer = page.locator('.react-flow__node[data-id^="peer-"]');
|
||||
await expect(peer).toHaveCount(1);
|
||||
|
||||
await connectNodes(page, peer, group);
|
||||
await expect(page.getByTestId("policy-name")).toBeVisible();
|
||||
await submitCreatePolicyModal(page);
|
||||
|
||||
// The policy node and edges exist on canvas…
|
||||
await expect(canvasNode(page, "policy-new-")).toHaveCount(1);
|
||||
// …but a policy referencing an uninstalled placeholder peer is
|
||||
// incomplete: only the group create and the pending peer install are
|
||||
// tracked.
|
||||
const changes = await readDraftChanges(page);
|
||||
expect(changes.filter((c) => c.type === "create-policy")).toHaveLength(0);
|
||||
expect(changes.filter((c) => c.type === "install-peer")).toHaveLength(1);
|
||||
await expectChangeCount(page, 2);
|
||||
});
|
||||
|
||||
test("Should rename a group and propagate the name into the policy changeset", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
const pane = await page.locator(".react-flow__pane").boundingBox();
|
||||
if (!pane) throw new Error("no pane");
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.45,
|
||||
y: pane.y + pane.height * 0.35,
|
||||
});
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.75,
|
||||
y: pane.y + pane.height * 0.65,
|
||||
});
|
||||
const groups = canvasNode(page, "group-new-");
|
||||
await connectNodes(page, groups.nth(0), groups.nth(1));
|
||||
await submitCreatePolicyModal(page);
|
||||
await expectChangeCount(page, 3);
|
||||
|
||||
// Rename the source group via its context menu.
|
||||
const sourceGroup = groups.nth(0);
|
||||
const newName = generateRandomName("cc-renamed-");
|
||||
await clickContextMenuItem(page, sourceGroup, "Rename");
|
||||
await page.getByTestId("cc-rename-input").fill(newName);
|
||||
await page.getByTestId("cc-rename-submit").click();
|
||||
|
||||
// The canvas node shows the new name…
|
||||
await expect(sourceGroup).toContainText(newName);
|
||||
// …the rename folded into the create-group entry (still 3 changes)…
|
||||
await expectChangeCount(page, 3);
|
||||
const changes = await readDraftChanges(page);
|
||||
expect(
|
||||
changes.some((c) => c.type === "create-group" && c.name === newName),
|
||||
).toBe(true);
|
||||
// …and the policy's group reference followed the rename.
|
||||
const policy = changes.find((c) => c.type === "create-policy");
|
||||
const rule = policy.policy.rules[0];
|
||||
const referenced = [...(rule.sources ?? []), ...(rule.destinations ?? [])]
|
||||
.map((g) => (typeof g === "string" ? g : g.name));
|
||||
expect(referenced).toContain(newName);
|
||||
|
||||
// Reopening the policy editor must show the renamed group SELECTED in
|
||||
// the group selector (the modal seeds from the canvas policy node).
|
||||
const policyNode = canvasNode(page, "policy-new-");
|
||||
await clickContextMenuItem(page, policyNode, "Edit");
|
||||
await expect(page.getByTestId("source-group-selector")).toContainText(
|
||||
newName,
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
test("Should create a group inline in the policy modal and show it on canvas", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
const pane = await page.locator(".react-flow__pane").boundingBox();
|
||||
if (!pane) throw new Error("no pane");
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.45,
|
||||
y: pane.y + pane.height * 0.35,
|
||||
});
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.75,
|
||||
y: pane.y + pane.height * 0.65,
|
||||
});
|
||||
const groups = canvasNode(page, "group-new-");
|
||||
await connectNodes(page, groups.nth(0), groups.nth(1));
|
||||
await expect(page.getByTestId("policy-name")).toBeVisible();
|
||||
|
||||
// Type a brand-new group name into the destination selector and add it.
|
||||
const inlineName = generateRandomName("cc-inline-");
|
||||
await page.getByTestId("destination-group-selector").click();
|
||||
await page
|
||||
.getByTestId("destination-group-selector-search")
|
||||
.fill(inlineName);
|
||||
await page.keyboard.press("Enter");
|
||||
await page.keyboard.press("Escape"); // close the selector dropdown
|
||||
await submitCreatePolicyModal(page);
|
||||
|
||||
// The inline group exists on canvas as its own draft node and as a
|
||||
// create-group change.
|
||||
await expect(
|
||||
page.locator(".react-flow__node", { hasText: inlineName }),
|
||||
).toHaveCount(1);
|
||||
const changes = await readDraftChanges(page);
|
||||
expect(
|
||||
changes.some(
|
||||
(c) => c.type === "create-group" && c.name === inlineName,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("Should edit a draft policy and reflect the change on the canvas", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
const pane = await page.locator(".react-flow__pane").boundingBox();
|
||||
if (!pane) throw new Error("no pane");
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.45,
|
||||
y: pane.y + pane.height * 0.35,
|
||||
});
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.75,
|
||||
y: pane.y + pane.height * 0.65,
|
||||
});
|
||||
const groups = canvasNode(page, "group-new-");
|
||||
await connectNodes(page, groups.nth(0), groups.nth(1));
|
||||
await submitCreatePolicyModal(page);
|
||||
const policyNode = canvasNode(page, "policy-new-");
|
||||
await expect(policyNode).toHaveCount(1);
|
||||
|
||||
// Edit via context menu, rename the policy on the General tab.
|
||||
const newName = generateRandomName("cc-policy-");
|
||||
await clickContextMenuItem(page, policyNode, "Edit");
|
||||
await expect(page.getByTestId("policy-name")).toBeVisible();
|
||||
await page.getByTestId("policy-name").fill(newName);
|
||||
await page.getByTestId("submit-policy").click();
|
||||
|
||||
// Canvas node text and the pending create-policy entry both updated.
|
||||
await expect(policyNode).toContainText(newName);
|
||||
const changes = await readDraftChanges(page);
|
||||
const policy = changes.find((c) => c.type === "create-policy");
|
||||
expect(policy.policy.name).toBe(newName);
|
||||
});
|
||||
|
||||
test("Should remove a draft policy from the canvas without a confirmation", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
const pane = await page.locator(".react-flow__pane").boundingBox();
|
||||
if (!pane) throw new Error("no pane");
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.45,
|
||||
y: pane.y + pane.height * 0.35,
|
||||
});
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.75,
|
||||
y: pane.y + pane.height * 0.65,
|
||||
});
|
||||
const groups = canvasNode(page, "group-new-");
|
||||
await connectNodes(page, groups.nth(0), groups.nth(1));
|
||||
await submitCreatePolicyModal(page);
|
||||
await expectChangeCount(page, 3);
|
||||
|
||||
// Remove is canvas-only: no confirm dialog, pending create cancelled,
|
||||
// endpoint groups stay on canvas.
|
||||
const policyNode = canvasNode(page, "policy-new-");
|
||||
await clickContextMenuItem(page, policyNode, "Remove");
|
||||
await expect(page.getByTestId("confirmation.confirm")).not.toBeVisible();
|
||||
await expect(policyNode).toHaveCount(0);
|
||||
await expect(page.locator(".react-flow__edge")).toHaveCount(0);
|
||||
await expect(groups).toHaveCount(2);
|
||||
await expectChangeCount(page, 2);
|
||||
});
|
||||
|
||||
test("Should drop a peer into a group and track the membership", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
const pane = await page.locator(".react-flow__pane").boundingBox();
|
||||
if (!pane) throw new Error("no pane");
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.45,
|
||||
y: pane.y + pane.height * 0.35,
|
||||
});
|
||||
await dragTemplateToCanvas(page, "cc-template-peer-user-device", {
|
||||
x: pane.x + pane.width * 0.75,
|
||||
y: pane.y + pane.height * 0.65,
|
||||
});
|
||||
const group = canvasNode(page, "group-new-").first();
|
||||
const peer = page.locator('.react-flow__node[data-id^="peer-"]');
|
||||
await expect(peer).toHaveCount(1);
|
||||
|
||||
// Dragging the peer NODE onto the group absorbs it as a member: the
|
||||
// peer node leaves the canvas and the group's counter increments.
|
||||
await dragNodeOnto(page, peer, group);
|
||||
await expect(peer).toHaveCount(0);
|
||||
await expect(group).toContainText(/1\s*peer/i);
|
||||
|
||||
// The membership folded into the group's create entry.
|
||||
const changes = await readDraftChanges(page);
|
||||
const createGroupChange = changes.find(
|
||||
(c) => c.type === "create-group",
|
||||
);
|
||||
expect(createGroupChange.peerIds).toHaveLength(1);
|
||||
// group create + the peer's pending install step
|
||||
await expectChangeCount(page, 2);
|
||||
|
||||
// The group's Details panel lists the dropped (placeholder) peer even
|
||||
// though it isn't in the API peer list.
|
||||
await clickContextMenuItem(page, group, "Details");
|
||||
await expect(
|
||||
page.getByText("User Device", { exact: false }).last(),
|
||||
).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
test("Should delete an existing group in draft and deploy the deletion", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
// Seed a real group via the API, then reload so the dashboard and the
|
||||
// components panel know it.
|
||||
const name = generateRandomName("cc-delete-");
|
||||
const seeded = await createGroup(page, name);
|
||||
await page.reload();
|
||||
await expect(page.locator(".react-flow__pane")).toBeVisible();
|
||||
await enterDraft(page);
|
||||
|
||||
// Pull the existing group onto the canvas from the components panel
|
||||
// (search narrows the virtualized list so the row is rendered).
|
||||
await page.getByTestId("cc-toolbar-add").click();
|
||||
await page.getByPlaceholder(/Search components/).fill(name);
|
||||
await dragTemplateToCanvas(page, `cc-panel-group-${seeded.id}`);
|
||||
|
||||
const groupNode = page.locator(
|
||||
`.react-flow__node[data-id="group-${seeded.id}"]`,
|
||||
);
|
||||
await expect(groupNode).toHaveCount(1);
|
||||
|
||||
// Delete (not Remove) an existing group: confirmed, tracked, deployed.
|
||||
await clickContextMenuItem(page, groupNode, "Delete");
|
||||
await expect(
|
||||
page.getByText(`Delete group "${name}"?`),
|
||||
).toBeVisible();
|
||||
await page.getByTestId("confirmation.confirm").click();
|
||||
await expect(groupNode).toHaveCount(0);
|
||||
await expectChangeCount(page, 1);
|
||||
|
||||
await reviewButton(page).click();
|
||||
const deleteResponse = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes(`/api/groups/${seeded.id}`) &&
|
||||
resp.request().method() === "DELETE",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.getByTestId("cc-deploy").click({ force: true });
|
||||
const response = await deleteResponse;
|
||||
expect(response.status()).toBeLessThan(300);
|
||||
|
||||
const remaining = await listGroups(page);
|
||||
expect(remaining.some((g) => g.id === seeded.id)).toBe(false);
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
// Safety net: clear any seeded groups left behind by failed runs.
|
||||
const context = await browser.newContext({
|
||||
storageState: "e2e/fixtures/auth/owner.json",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto("/peers");
|
||||
const groups = await listGroups(page).catch(() => []);
|
||||
for (const g of groups) {
|
||||
if (g.name.startsWith("cc-delete-") || g.name.startsWith("cc-inline-")) {
|
||||
await deleteGroup(page, g.id).catch(() => {});
|
||||
}
|
||||
}
|
||||
await context.close();
|
||||
});
|
||||
},
|
||||
);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,156 +0,0 @@
|
||||
import { Page, expect } from "@playwright/test";
|
||||
import { test } from "../helpers/fixtures";
|
||||
import { deleteGroup, listGroups } from "../helpers/api";
|
||||
import {
|
||||
CHANGES_KEY,
|
||||
CANVAS_KEY,
|
||||
canvasNode,
|
||||
dragTemplateToCanvas,
|
||||
enterDraft,
|
||||
expectChangeCount,
|
||||
resetDraftState,
|
||||
reviewButton,
|
||||
} from "../helpers/control-center";
|
||||
|
||||
test.describe.serial("Control Center Draft Mode @control-center", () => {
|
||||
test.beforeEach(async ({ dashboardAsOwner: page }) => {
|
||||
// Every test starts from a clean live view: no leftover draft storage
|
||||
// from a previous (possibly failed) test.
|
||||
await resetDraftState(page);
|
||||
});
|
||||
|
||||
test("Should enter and leave draft mode without changes", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
await expect(page.getByTestId("cc-draft-cancel")).toBeVisible();
|
||||
await expectChangeCount(page, 0);
|
||||
|
||||
// No pending changes — switching back to live must not ask to confirm.
|
||||
await page.getByTestId("cc-mode-live").click();
|
||||
await expect(page.getByTestId("confirmation.confirm")).not.toBeVisible();
|
||||
await expect(page.getByTestId("cc-toolbar-add")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("Should add a draft group via drag and drop and track the change", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
await dragTemplateToCanvas(page, "cc-template-group");
|
||||
|
||||
await expect(canvasNode(page, "group-new-")).toHaveCount(1);
|
||||
await expectChangeCount(page, 1);
|
||||
|
||||
// The changeset is persisted to localStorage on every update.
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((key) => localStorage.getItem(key), CHANGES_KEY),
|
||||
)
|
||||
.toContain("create-group");
|
||||
});
|
||||
|
||||
test("Should undo and redo the dropped group", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
await dragTemplateToCanvas(page, "cc-template-group");
|
||||
const groupNode = canvasNode(page, "group-new-");
|
||||
await expect(groupNode).toHaveCount(1);
|
||||
|
||||
// History snapshots are debounced (300ms) — wait for Undo to arm
|
||||
// instead of clicking immediately.
|
||||
const undo = page.getByTestId("cc-toolbar-undo");
|
||||
await expect(undo).toBeEnabled();
|
||||
await undo.click();
|
||||
await expect(groupNode).toHaveCount(0);
|
||||
await expectChangeCount(page, 0);
|
||||
|
||||
const redo = page.getByTestId("cc-toolbar-redo");
|
||||
await expect(redo).toBeEnabled();
|
||||
await redo.click();
|
||||
await expect(groupNode).toHaveCount(1);
|
||||
await expectChangeCount(page, 1);
|
||||
});
|
||||
|
||||
test("Should restore the draft from storage after a reload", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
await dragTemplateToCanvas(page, "cc-template-group");
|
||||
await expectChangeCount(page, 1);
|
||||
// Wait for the canvas snapshot to land in storage before reloading.
|
||||
await expect
|
||||
.poll(() => page.evaluate((key) => localStorage.getItem(key), CANVAS_KEY))
|
||||
.toContain("group-new-");
|
||||
|
||||
await page.reload();
|
||||
await expect(page.locator(".react-flow__pane")).toBeVisible();
|
||||
// A reload returns to live; re-entering draft restores the snapshot
|
||||
// instead of rebuilding from the live view.
|
||||
await enterDraft(page);
|
||||
await expect(canvasNode(page, "group-new-")).toHaveCount(1);
|
||||
await expectChangeCount(page, 1);
|
||||
});
|
||||
|
||||
test("Should confirm before discarding draft changes", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
await dragTemplateToCanvas(page, "cc-template-group");
|
||||
await expectChangeCount(page, 1);
|
||||
|
||||
// Cancelling the confirmation keeps the draft.
|
||||
await page.getByTestId("cc-draft-cancel").click();
|
||||
await expect(page.getByText("Discard draft changes?")).toBeVisible();
|
||||
await page.getByTestId("confirmation.cancel").click();
|
||||
await expect(page.getByTestId("cc-toolbar-add")).toBeVisible();
|
||||
await expectChangeCount(page, 1);
|
||||
|
||||
// Confirming discards and returns to live with storage cleared.
|
||||
await page.getByTestId("cc-draft-cancel").click();
|
||||
await page.getByTestId("confirmation.confirm").click();
|
||||
await expect(page.getByTestId("cc-toolbar-add")).not.toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((key) => localStorage.getItem(key), CHANGES_KEY),
|
||||
)
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
test("Should deploy a created group to the live account", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
await dragTemplateToCanvas(page, "cc-template-group");
|
||||
await expectChangeCount(page, 1);
|
||||
|
||||
await reviewButton(page).click();
|
||||
await expect(page.getByText("Review & Deploy")).toBeVisible();
|
||||
// The single pending change is a group create.
|
||||
await expect(page.getByText("Create", { exact: true })).toBeVisible();
|
||||
|
||||
const createResponse = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/api/groups") &&
|
||||
resp.request().method() === "POST",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.getByTestId("cc-deploy").click({ force: true });
|
||||
const response = await createResponse;
|
||||
expect([200, 201]).toContain(response.status());
|
||||
const created = await response.json();
|
||||
|
||||
// Deploy exits the draft back to the rebuilt live view.
|
||||
await expect(page.getByTestId("cc-toolbar-add")).not.toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((key) => localStorage.getItem(key), CHANGES_KEY),
|
||||
)
|
||||
.toBeNull();
|
||||
|
||||
// The group really exists in the account — then clean it up.
|
||||
const groups = await listGroups(page);
|
||||
expect(groups.some((g) => g.id === created.id)).toBe(true);
|
||||
await deleteGroup(page, created.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Validates that the dashboard surfaces the management API's request id in the
|
||||
* error toast. Newer management servers set an `X-Request-Id` response header
|
||||
* (see telemetry.RequestIDHeader). When present, the dashboard shows it in the
|
||||
* error notification so it can be quoted in support tickets. Older servers omit
|
||||
* the header, and the toast must render without a request id.
|
||||
*
|
||||
* Each scenario runs in its own browser context so the mocked `/networks`
|
||||
* route and its resulting toast never bleed into another test through the
|
||||
* worker-scoped page.
|
||||
*/
|
||||
import { test, expect, type Browser, type Page } from "@playwright/test";
|
||||
import { loginToApp, navigateTo } from "../helpers/auth";
|
||||
import * as path from "path";
|
||||
|
||||
const SCREENSHOTS_DIR = path.resolve(__dirname, "..", "screenshots");
|
||||
const REQUEST_ID = "9m4e2mr0ui3e8a215n4g";
|
||||
const NETWORKS_ENDPOINT = /\/api\/networks(\?|$)/;
|
||||
const REQUEST_ID_TESTID = "notification-request-id";
|
||||
const TITLE_TESTID = "notification-title";
|
||||
|
||||
// failNetworks mocks the networks list GET with a 500, optionally attaching the
|
||||
// X-Request-Id header to emulate a newer management server. The dashboard talks
|
||||
// to the management API cross-origin, so the mock must mirror a CORS-enabled
|
||||
// server: the browser only lets JS read a non-safelisted response header when it
|
||||
// is listed in Access-Control-Expose-Headers. This is the same requirement the
|
||||
// real management server must satisfy for the feature to work in production.
|
||||
async function failNetworks(page: Page, opts: { withRequestId: boolean }) {
|
||||
await page.route(NETWORKS_ENDPOINT, async (route) => {
|
||||
if (route.request().method() !== "GET") return route.continue();
|
||||
const origin = route.request().headers()["origin"] || "*";
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
"access-control-allow-origin": origin,
|
||||
"access-control-expose-headers": "X-Request-Id",
|
||||
};
|
||||
if (opts.withRequestId) headers["X-Request-Id"] = REQUEST_ID;
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
headers,
|
||||
body: JSON.stringify({ code: 500, message: "internal server error" }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openOwner(
|
||||
browser: Browser,
|
||||
): Promise<{ page: Page; close: () => Promise<void> }> {
|
||||
const context = await browser.newContext({
|
||||
storageState: "e2e/fixtures/auth/owner.json",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
return { page, close: () => context.close() };
|
||||
}
|
||||
|
||||
test.describe.serial("API error request id @error-handling", () => {
|
||||
test("shows the X-Request-Id from a failed request in the error toast", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openOwner(browser);
|
||||
try {
|
||||
await failNetworks(page, { withRequestId: true });
|
||||
await loginToApp(page, "owner");
|
||||
await navigateTo(page, "/networks");
|
||||
|
||||
const requestIdLine = page.getByTestId(REQUEST_ID_TESTID);
|
||||
await expect(requestIdLine).toBeVisible();
|
||||
await expect(requestIdLine).toContainText(REQUEST_ID);
|
||||
await expect(page.getByTestId(TITLE_TESTID).first()).toContainText(
|
||||
"Request failed with status code 500",
|
||||
);
|
||||
|
||||
const toast = page.locator("[data-toast-notification]").first();
|
||||
await toast.screenshot({
|
||||
path: path.join(SCREENSHOTS_DIR, "error-toast-with-request-id.png"),
|
||||
});
|
||||
await page.screenshot({
|
||||
path: path.join(SCREENSHOTS_DIR, "networks-page-error-request-id.png"),
|
||||
});
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("omits the request id when the server does not send the header", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openOwner(browser);
|
||||
try {
|
||||
await failNetworks(page, { withRequestId: false });
|
||||
await loginToApp(page, "owner");
|
||||
await navigateTo(page, "/networks");
|
||||
|
||||
// The generic error toast still appears for older servers...
|
||||
await expect(page.getByTestId(TITLE_TESTID).first()).toContainText(
|
||||
"Request failed with status code 500",
|
||||
);
|
||||
// ...but no request id line is rendered.
|
||||
await expect(page.getByTestId(REQUEST_ID_TESTID)).toHaveCount(0);
|
||||
|
||||
const toast = page.locator("[data-toast-notification]").first();
|
||||
await toast.screenshot({
|
||||
path: path.join(SCREENSHOTS_DIR, "error-toast-without-request-id.png"),
|
||||
});
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
});
|
||||
Loaded 20 of 243 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user