mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
@@ -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,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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Onboarding form selection spec.
|
||||
*
|
||||
* Validates which onboarding form is shown first and which one persists,
|
||||
* across signup-source and account states — the recurring "regular form
|
||||
* flashes before the Agent Network form" bug. Onboarding is disabled in the
|
||||
* test build by default; each test opts in via the `netbird-test-onboarding`
|
||||
* localStorage flag (see testOnboardingEnabled in src/utils/netbird.ts), and
|
||||
* GET /accounts is rewritten to drive the account's onboarding + settings
|
||||
* state (same interception approach as edition-gating.spec.ts).
|
||||
*/
|
||||
import { test, expect, type Browser, type Page } from "@playwright/test";
|
||||
import { loginToApp } from "../helpers/auth";
|
||||
|
||||
const SIGNUP_SOURCE_KEY = "netbird-signup-source";
|
||||
const AGENT_NETWORK_SOURCE = "netbird.ai";
|
||||
|
||||
const AGENT_FORM = "agent-network-onboarding";
|
||||
const REGULAR_FORM = "regular-onboarding";
|
||||
|
||||
type AccountState = {
|
||||
signupFormPending?: boolean;
|
||||
onboardingFlowPending?: boolean;
|
||||
agentNetworkOnly?: boolean;
|
||||
};
|
||||
|
||||
// mockAccounts rewrites GET /accounts to drive the onboarding + focused-view
|
||||
// state, optionally delaying the response to emulate a slow backend. A PUT to
|
||||
// /accounts/{id} flips agent_network_only to true afterwards, mirroring the
|
||||
// server persisting the netbird.ai signup mark.
|
||||
function mockAccounts(page: Page, state: AccountState, delayMs: number) {
|
||||
let agentNetworkOnly = state.agentNetworkOnly;
|
||||
|
||||
page.route("**/api/accounts", async (route) => {
|
||||
if (route.request().method() !== "GET") return route.continue();
|
||||
if (delayMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
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].onboarding = {
|
||||
...(body[0].onboarding ?? {}),
|
||||
signup_form_pending: !!state.signupFormPending,
|
||||
onboarding_flow_pending: !!state.onboardingFlowPending,
|
||||
};
|
||||
body[0].settings = { ...(body[0].settings ?? {}) };
|
||||
if (agentNetworkOnly === undefined) {
|
||||
delete body[0].settings.agent_network_only;
|
||||
} else {
|
||||
body[0].settings.agent_network_only = agentNetworkOnly;
|
||||
}
|
||||
}
|
||||
return route.fulfill({ response, json: body });
|
||||
});
|
||||
|
||||
page.route("**/api/accounts/*", async (route) => {
|
||||
if (route.request().method() !== "PUT") return route.continue();
|
||||
agentNetworkOnly = true;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ settings: { agent_network_only: true } }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openOnboarding(
|
||||
browser: Browser,
|
||||
opts: { source?: boolean; account: AccountState; delayMs?: number },
|
||||
): Promise<{ page: Page; close: () => Promise<void> }> {
|
||||
const context = await browser.newContext({
|
||||
storageState: "e2e/fixtures/auth/owner.json",
|
||||
});
|
||||
await context.addInitScript(
|
||||
([sourceKey, sourceValue, withSource]) => {
|
||||
try {
|
||||
window.localStorage.setItem("netbird-test-onboarding", "true");
|
||||
if (withSource) {
|
||||
window.localStorage.setItem(
|
||||
sourceKey as string,
|
||||
sourceValue as string,
|
||||
);
|
||||
}
|
||||
} catch (e) {}
|
||||
},
|
||||
[SIGNUP_SOURCE_KEY, AGENT_NETWORK_SOURCE, !!opts.source] as const,
|
||||
);
|
||||
const page = await context.newPage();
|
||||
mockAccounts(page, opts.account, opts.delayMs ?? 0);
|
||||
await loginToApp(page, "owner", { expectOnboarding: true });
|
||||
return { page, close: () => context.close() };
|
||||
}
|
||||
|
||||
test.describe.serial("Onboarding form selection @onboarding", () => {
|
||||
test("netbird.ai signup shows the Agent Network form and never the regular one", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openOnboarding(browser, {
|
||||
source: true,
|
||||
account: { signupFormPending: true, onboardingFlowPending: true },
|
||||
});
|
||||
try {
|
||||
await expect(page.getByTestId(AGENT_FORM)).toBeVisible();
|
||||
await expect(page.getByTestId(REGULAR_FORM)).toHaveCount(0);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a plain new cloud account shows the regular onboarding form", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openOnboarding(browser, {
|
||||
source: false,
|
||||
account: { signupFormPending: true, onboardingFlowPending: true },
|
||||
});
|
||||
try {
|
||||
await expect(page.getByTestId(REGULAR_FORM)).toBeVisible();
|
||||
await expect(page.getByTestId(AGENT_FORM)).toHaveCount(0);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("an account already marked agent_network_only shows the Agent Network form", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openOnboarding(browser, {
|
||||
source: false,
|
||||
account: { onboardingFlowPending: true, agentNetworkOnly: true },
|
||||
});
|
||||
try {
|
||||
await expect(page.getByTestId(AGENT_FORM)).toBeVisible();
|
||||
await expect(page.getByTestId(REGULAR_FORM)).toHaveCount(0);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a slow backend never flashes the regular form for a netbird.ai signup", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openOnboarding(browser, {
|
||||
source: true,
|
||||
account: { signupFormPending: true, onboardingFlowPending: true },
|
||||
delayMs: 1500,
|
||||
});
|
||||
try {
|
||||
// While /accounts is still loading, no onboarding form should have been
|
||||
// committed to the wrong flow.
|
||||
await expect(page.getByTestId(REGULAR_FORM)).toHaveCount(0);
|
||||
// Once it resolves, the Agent Network form is the one that appears...
|
||||
await expect(page.getByTestId(AGENT_FORM)).toBeVisible();
|
||||
await expect(page.getByTestId(REGULAR_FORM)).toHaveCount(0);
|
||||
// ...and it persists after the setting is applied (no flip/close).
|
||||
await page.waitForTimeout(1500);
|
||||
await expect(page.getByTestId(AGENT_FORM)).toBeVisible();
|
||||
await expect(page.getByTestId(REGULAR_FORM)).toHaveCount(0);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -137,6 +137,7 @@ async function revokeSetupKey(
|
||||
);
|
||||
await page.getByTestId("confirmation.confirm").click();
|
||||
await responsePromise;
|
||||
await expect(page.getByRole("dialog")).not.toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator("tr")
|
||||
@@ -158,4 +159,5 @@ async function deleteSetupKey(
|
||||
await page.getByTestId("delete-setup-key").click({ force: true });
|
||||
await page.getByTestId("confirmation.confirm").click();
|
||||
await expect(page.locator("tr").filter({ hasText: name })).not.toBeVisible();
|
||||
await expect(page.getByRole("dialog")).not.toBeVisible();
|
||||
}
|
||||
Generated
+25
-1913
File diff suppressed because it is too large.
Load diff
Loaded 20 of 230 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user