Compare commits

..
341 changed files with 5627 additions and 52176 deletions

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
{
"extends": ["next/core-web-vitals","prettier"],
"plugins": ["simple-import-sort"],
"rules": {
"simple-import-sort/imports": [
"warn",
{
"groups": [["^\\u0000", "^@?\\w", "^[^.]", "^\\."]]
}
],
"simple-import-sort/exports": "warn"
}
}
+2 -5
View File
@@ -99,14 +99,12 @@ jobs:
type=sha
- name: Log in to Docker Hub
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: docker/login-action@v3
with:
username: ${{ secrets.NB_DOCKER_USER }}
password: ${{ secrets.NB_DOCKER_TOKEN }}
- name: Log in to the GitHub Container registry
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: docker/login-action@v3
with:
registry: ghcr.io
@@ -118,12 +116,11 @@ jobs:
with:
context: .
file: docker/Dockerfile
push: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
push: true
platforms: linux/amd64,linux/arm64,linux/arm
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
run: |
- run: |
echo '### Pushed tags' >> $GITHUB_STEP_SUMMARY
echo '${{ steps.meta.outputs.tags }}' >> $GITHUB_STEP_SUMMARY
-81
View File
@@ -1,81 +0,0 @@
name: Bump netbird wasm client
on:
workflow_dispatch:
inputs:
tag:
description: 'NetBird release tag (e.g. v0.78.0)'
required: true
type: string
concurrency:
group: ${{ github.workflow }}-${{ inputs.tag }}
cancel-in-progress: false
jobs:
bump:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: main
token: ${{ secrets.NETBIRD_DEV_BOT_GITHUB_TOKEN }}
- name: Configure git
run: |
git config --global user.email "dev@netbird.io"
git config --global user.name "netbirddev"
# The tag rides in env and is validated before use, so a crafted dispatch
# input cannot expand into shell source or reach the sed expression.
- name: Update the default wasm path
id: bump
env:
TAG: ${{ inputs.tag }}
run: |
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Refusing tag '$TAG': expected vMAJOR.MINOR.PATCH"
exit 1
fi
sed -i "s|https://pkgs.netbird.io/wasm/client/v[0-9.]*|https://pkgs.netbird.io/wasm/client/${TAG}|" src/utils/config.ts
branch="bump-netbird-wasm-${TAG#v}"
echo "branch=${branch}" >> "$GITHUB_OUTPUT"
# The branch name is derived from the tag, so a re-dispatch of the
# same release would collide with the branch and pull request an
# earlier run left behind.
if git ls-remote --exit-code --heads origin "${branch}" >/dev/null 2>&1; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Branch ${branch} already exists, ${TAG} was handled by an earlier run"
exit 0
fi
git checkout -b "${branch}"
git add src/utils/config.ts
if git diff --cached --quiet; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Default wasm path already at ${TAG}, nothing to do"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
git commit -m "Bump the WASM client to ${TAG}"
git push --set-upstream origin "${branch}"
- name: Create pull request
if: steps.bump.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.NETBIRD_DEV_BOT_GITHUB_TOKEN }}
TAG: ${{ inputs.tag }}
BRANCH: ${{ steps.bump.outputs.branch }}
run: |
gh pr create \
--base main \
--head "$BRANCH" \
--title "Bump the WASM client to ${TAG}" \
--body "Automated wasm path bump triggered by netbird release ${TAG}."
+3 -47
View File
@@ -101,61 +101,17 @@ jobs:
echo "Using reverse-proxy tag: $TAG"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Restore geolocation database cache
id: geo-cache
uses: actions/cache/restore@v4
with:
path: e2e/environment/geo-cache
key: geolite-dbs-v1
- name: Build dashboard and set up test environment
- name: Setup test environment
env:
MANAGEMENT_IMAGE_TAG: ${{ steps.management_tag.outputs.tag }}
REVERSE_PROXY_IMAGE_TAG: ${{ steps.reverse_proxy_tag.outputs.tag }}
# The docker environment (image pulls, Zitadel bootstrap) is
# network-bound and the Next build is CPU-bound, so they overlap.
# The build reads .test-config.json via the @/config/test alias,
# and create-test-env.sh renders that file only after the Zitadel
# bootstrap — so the build waits for it before starting.
run: |
(cd ./e2e/environment && bash create-test-env.sh) > env-setup.log 2>&1 &
ENV_PID=$!
until [ -s .test-config.json ] && jq empty .test-config.json 2>/dev/null; do
if ! kill -0 "$ENV_PID" 2>/dev/null; then break; fi
sleep 2
done
if ! [ -s .test-config.json ]; then
echo "::error::create-test-env.sh exited before rendering .test-config.json"
cat env-setup.log
exit 1
fi
# Lowest CPU priority: the containers must win the scheduler, or
# the build can starve management past the proxy-registration wait.
if ! nice -n 19 env APP_ENV=test npx next build; then
kill "$ENV_PID" 2>/dev/null || true
exit 1
fi
if ! wait "$ENV_PID"; then
echo "::error::Test environment setup failed"
cat env-setup.log
exit 1
fi
cat env-setup.log
# Saved right after env setup rather than in a post step, so a later
# test failure doesn't lose the cache.
- name: Save geolocation database cache
if: always() && steps.geo-cache.outputs.cache-hit != 'true' && hashFiles('e2e/environment/geo-cache/**') != ''
uses: actions/cache/save@v4
with:
path: e2e/environment/geo-cache
key: geolite-dbs-v1
run: cd ./e2e/environment && bash create-test-env.sh
- name: Run Playwright tests
id: playwright
run: |
set -o pipefail
npx playwright test --config=e2e/playwright.config.ts 2>&1 | tee playwright-output.log
npm run test:ci 2>&1 | tee playwright-output.log
- name: Append Playwright summary to job summary
if: always() && hashFiles('e2e/test-results/results.json') != ''
-32
View File
@@ -1,32 +0,0 @@
name: Unit Tests
on:
push:
branches:
- main
pull_request:
# Cancel superseded runs on the same ref so a new push doesn't queue behind an
# outdated one (mirrors build_and_push.yml).
concurrency:
group: unit-tests-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
vitest:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: setup-node
uses: actions/setup-node@v5
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:unit
-1
View File
@@ -44,7 +44,6 @@ e2e/test-results/
e2e/playwright-report/
/test-results/
/playwright-report/
playwright-report*.zip
.configs/.local-config.zitadel.json
.configs/.staging-config.json
.configs/.temp-config.json
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.24
FROM alpine:3.14
RUN apk add --no-cache bash curl less ca-certificates git tzdata zip gettext \
nginx curl supervisor certbot-nginx && \
-1
View File
@@ -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-*.spec.ts` (`-draft`, `-draft-connections`, `-draft-matrix`, `-draft-extras`, `-draft-install`, `-draft-remove`, `-draft-shortcuts`, `-deploy`, `-drilldown`, `-live`, `-peers`) | `@control-center` |
## Debugging
+1 -4
View File
@@ -1,7 +1,6 @@
# Ignore zitadel environment
.env
Caddyfile
agentgateway-stub.Caddyfile
management.json
turnserver.conf
zitadel.env
@@ -10,6 +9,4 @@ proxy-no-ports.env
proxy-certs/
proxy-certs-no-ports/
docker-compose.yml
/machinekey
geo-cache/
geo-dump/
/machinekey
+1 -1
View File
@@ -19,7 +19,7 @@ check_docker_compose() {
DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
$DOCKER_COMPOSE_COMMAND down --volumes
rm -f docker-compose.yml Caddyfile agentgateway-stub.Caddyfile zitadel.env .env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env
rm -f docker-compose.yml Caddyfile zitadel.env .env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env
rm -rf proxy-certs proxy-certs-no-ports
rm -f ../../.test-config.json ../playwright.env.json
rm -f ../fixtures/auth/owner.json ../fixtures/auth/user.json
+3 -87
View File
@@ -61,13 +61,7 @@ wait_proxy_cluster() {
SERVICE_NAME=${1:-reverse-proxy}
echo -n "Waiting for $SERVICE_NAME to register with management "
set +e
# 480s: management downloads its geolocation databases on first boot
# (the proxy specs need /locations/countries, so it cannot be disabled
# there), and a slow pkgs.netbird.io can stall that well past the old
# 120s budget. The proxies skip their download entirely via
# NB_PROXY_DISABLE_GEOLOCATION. The loop exits as soon as the sync
# lands, so healthy runs pay nothing.
local attempts=240
local attempts=60
local i
for ((i = 1; i <= attempts; i++)); do
if $DOCKER_COMPOSE_COMMAND logs "$SERVICE_NAME" 2>&1 | grep -q "Initial mapping sync complete"; then
@@ -532,7 +526,7 @@ initEnvironment() {
echo "Generated files already exist, if you want to reinitialize the environment, please remove them first."
echo "You can use the following commands:"
echo " $DOCKER_COMPOSE_COMMAND down --volumes # to remove all containers and volumes"
echo " rm -f docker-compose.yml Caddyfile agentgateway-stub.Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env && rm -rf proxy-certs proxy-certs-no-ports"
echo " rm -f docker-compose.yml Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env && rm -rf proxy-certs proxy-certs-no-ports"
echo "Be aware that this will remove all data from the database, and you will have to reconfigure the dashboard."
exit 1
fi
@@ -540,7 +534,6 @@ initEnvironment() {
echo Rendering initial files...
renderDockerCompose > docker-compose.yml
renderCaddyfile > Caddyfile
renderAgentGatewayStub > agentgateway-stub.Caddyfile
renderZitadelEnv > zitadel.env
echo "" > turnserver.conf
echo "" > management.json
@@ -596,8 +589,6 @@ initEnvironment() {
echo -e "\nCreating proxy access tokens...\n"
init_proxy_tokens
exportGeoDatabases
echo -e "\nStarting reverse proxy services...\n"
$DOCKER_COMPOSE_COMMAND up -d reverse-proxy reverse-proxy-no-ports
@@ -613,30 +604,6 @@ initEnvironment() {
echo "Password: $ZITADEL_ADMIN_PASSWORD" | tee -a .env
}
# Saving an Agent Network provider makes management call the upstream and
# refuses the save unless it answers a model listing, so a provider spec needs
# a reachable OpenAI-compatible endpoint. agentgateway is self-hosted and has
# no public one; this stub stands in for it, inside the compose network where
# management can reach it and without depending on a vendor being up.
renderAgentGatewayStub() {
cat <<'EOF'
{
admin off
auto_https off
}
:8088 {
handle /v1/models {
header Content-Type application/json
respond `{"object":"list","data":[{"id":"agentgateway-e2e-model","object":"model","owned_by":"netbird-e2e"}]}` 200
}
handle {
respond 404
}
}
EOF
}
renderCaddyfile() {
cat <<EOF
{
@@ -793,41 +760,7 @@ ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE=$ZIDATE_TOKEN_EXPIRATION_DA
EOF
}
# Copies management's geolocation databases out of its data volume into
# ./geo-cache so CI can cache them across runs. Management has finished
# loading them by the time its API serves requests. A no-op when the
# cache already holds both files, and never fatal: a failed export just
# means the next run downloads again.
exportGeoDatabases() {
mkdir -p geo-cache
if compgen -G "geo-cache/GeoLite2-City_*.mmdb" > /dev/null \
&& compgen -G "geo-cache/geonames_*.db" > /dev/null; then
return
fi
echo -e "\nExporting geolocation databases for caching...\n"
rm -rf geo-dump
if $DOCKER_COMPOSE_COMMAND cp management:/var/lib/netbird ./geo-dump; then
cp geo-dump/GeoLite2-City_*.mmdb geo-dump/geonames_*.db geo-cache/ 2>/dev/null \
|| echo "WARN: no geolocation databases found in management data dir"
rm -rf geo-dump
else
echo "WARN: could not copy management data dir; geolocation databases not cached"
fi
}
renderDockerCompose() {
# Cached geolocation databases (restored by CI into ./geo-cache) are
# mounted into management's data dir so it skips the slow first-boot
# download from pkgs.netbird.io. With no cache the mounts are omitted
# and management downloads as before.
local geo_mounts=""
local f
for f in geo-cache/GeoLite2-City_*.mmdb geo-cache/geonames_*.db; do
[ -f "$f" ] || continue
geo_mounts+="
- ./${f}:/var/lib/netbird/$(basename "$f")"
done
cat <<EOF
version: "3.4"
services:
@@ -843,15 +776,6 @@ services:
volumes:
- netbird_caddy_data:/data
- ./Caddyfile:/etc/caddy/Caddyfile
# Stand-in upstream for the Agent Network provider specs: answers the model
# listing management probes before it accepts a provider save. Reachable
# only from inside the compose network, at http://agentgateway-stub:8088.
agentgateway-stub:
image: caddy
restart: unless-stopped
networks: [ netbird ]
volumes:
- ./agentgateway-stub.Caddyfile:/etc/caddy/Caddyfile
# Management
management:
image: ghcr.io/netbirdio/management-cloud:${MANAGEMENT_IMAGE_TAG}
@@ -870,7 +794,7 @@ services:
- NB_TRAFFIC_FLOW_INTERVAL=20s
- NB_SINGLE_INSTANCE_MODE=true
volumes:
- netbird_management:/var/lib/netbird${geo_mounts}
- netbird_management:/var/lib/netbird
- ./management.json:/etc/netbird/management.json
command: [
"--port", "80",
@@ -921,11 +845,6 @@ services:
networks: [netbird]
env_file:
- ./proxy.env
environment:
# No spec exercises country-based enforcement, and skipping the
# GeoLite2 download removes a startup stall of up to 2 minutes
# when pkgs.netbird.io is slow.
- NB_PROXY_DISABLE_GEOLOCATION=true
volumes:
- ./proxy-certs:/certs:ro
command: [
@@ -947,9 +866,6 @@ services:
networks: [netbird]
env_file:
- ./proxy-no-ports.env
environment:
# See the primary proxy: no spec needs geo enforcement.
- NB_PROXY_DISABLE_GEOLOCATION=true
volumes:
# Distinct cert dir so this proxy has a distinct identity from the
# primary proxy; a shared cert makes both register under the same
+82 -269
View File
File diff suppressed because it is too large. Load diff
+1 -9
View File
@@ -117,15 +117,7 @@ export async function loginToApp(
* Use this instead of page.goto() for in-app navigation after loginToApp().
*/
export async function navigateTo(page: Page, path: string) {
// A client-side route update still in flight from the previous test (the
// dashboard page is shared per worker) aborts the navigation. Retry once —
// by then the app-driven navigation has landed.
try {
await page.goto(path, { waitUntil: "domcontentloaded" });
} catch (e) {
if (!String(e).includes("ERR_ABORTED")) throw e;
await page.goto(path, { waitUntil: "domcontentloaded" });
}
await page.goto(path, { waitUntil: "domcontentloaded" });
const modal = page.getByTestId("setup-netbird-modal");
try {
await modal.waitFor({ state: "visible", timeout: 3_000 });
-260
View File
@@ -1,260 +0,0 @@
import { Page, Locator, expect } from "@playwright/test";
import { navigateTo } from "./auth";
import { clearScrollLock, setTestEdition } from "./utils";
// The test build's billing modal backdrop swallows canvas clicks.
async function disableCloudBilling(page: Page) {
await setTestEdition(page, "licensed");
}
export async function dismissBlockingOverlays(page: Page) {
await clearScrollLock(page);
}
export type FlowView = "peers" | "users" | "groups" | "networks";
export async function openControlCenter(page: Page, tab?: FlowView) {
await disableCloudBilling(page);
await navigateTo(
page,
tab ? `/control-center?tab=${tab}` : "/control-center",
);
// The pane hides behind cc-prefit until the first fitView.
await expect(page.locator(".react-flow__pane")).toBeVisible();
await dismissBlockingOverlays(page);
}
/**
* Opens the groups view already showing `groupName`.
*
* The view picks its own group on first render (getFirstGroup: the highest
* peers_count that is a policy source), and that ranking runs over every group
* in the account — including ones a spec on the other worker seeded moments
* ago. Deleting the spec's own fixtures first is not enough to pin it, so
* select the group by name and let the view rebuild around it.
*/
export async function openGroupView(page: Page, groupName: string) {
await openControlCenter(page, "groups");
const selector = canvasNode(page, "select-group-node");
await expect(selector).toBeVisible();
// Unforced on purpose: the opening fitView animates the canvas, and the
// actionability check is what waits for the node to stop moving.
await selector.click();
await page.getByTestId("select-dropdown-search").fill(groupName);
await page
.locator('[role="option"]')
.filter({ hasText: groupName })
.first()
.click({ force: true });
await expect(selector).toContainText(groupName);
}
export async function switchFlowView(page: Page, view: FlowView) {
await dismissBlockingOverlays(page);
await page.getByTestId(`cc-flow-${view}`).click({ force: true });
await expect(page.getByTestId(`cc-flow-${view}`)).toHaveAttribute(
"data-state",
"active",
);
}
export async function enterDraft(page: Page) {
await dismissBlockingOverlays(page);
await page.getByTestId("cc-mode-draft").click({ force: true });
await page.getByTestId("cc-draft-use-current-option").click({ force: true });
// Waiting for the Add button also guarantees draft mode is fully active.
await expect(page.getByTestId("cc-toolbar-add")).toBeVisible();
}
// Draft state is React-only, so a full page load discards it.
export async function resetDraftState(page: Page) {
await openControlCenter(page);
}
export async function readDraftChanges(page: Page): Promise<any[]> {
return await page.evaluate(
() =>
(window as unknown as { __ccDraftChanges?: any[] }).__ccDraftChanges ??
[],
);
}
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 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();
// Let the drop handler commit the new node before callers assert on it.
await page.waitForTimeout(120);
}
// Networks share the Resources tab.
function categoryForTemplate(templateTestId: string): string {
if (templateTestId.includes("peer")) return "peers";
if (templateTestId.includes("policy")) return "policies";
if (templateTestId.includes("group")) return "groups";
return "resources";
}
/** The panel drags via raw pointer events, not HTML5 DnD. */
export async function dragTemplateToCanvas(
page: Page,
templateTestId: string,
target?: { x: number; y: number },
opts: { search?: string } = {},
) {
const item = page.getByTestId(templateTestId);
// The panel only fades out, so read the toggle's state from aria-pressed.
const addButton = page.getByTestId("cc-toolbar-add");
if ((await addButton.getAttribute("aria-pressed")) !== "true") {
await addButton.click();
await expect(addButton).toHaveAttribute("aria-pressed", "true");
}
// Dispatch directly: the tab's hover tooltip intercepts a real pointer click.
await page
.getByTestId(`cc-category-${categoryForTemplate(templateTestId)}`)
.dispatchEvent("click");
// Picking a category clears the search box, so fill it after the switch.
if (opts.search) {
await page.getByPlaceholder(/Search components/).fill(opts.search);
}
await expect(item).toBeVisible();
await item.scrollIntoViewIfNeeded();
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);
// A resource drop opens an editor modal that Escape would dismiss.
if (!templateTestId.includes("resource")) {
await page.keyboard.press("Escape");
}
}
export function canvasNode(page: Page, dataIdPrefix: string) {
return page.locator(`.react-flow__node[data-id^="${dataIdPrefix}"]`);
}
/** `sr` (right) makes the node a policy SOURCE, `sl` (left) a DESTINATION. */
export async function connectNodes(
page: Page,
source: Locator,
target: Locator,
handleSide: "sr" | "sl" = "sr",
) {
// Connect handles fade in on node hover, so wait for a stable position.
await source.hover();
const handle = source.locator(
`.react-flow__handle[data-handleid="${handleSide}-connect"]`,
);
await expect(handle).toBeVisible();
await page.waitForTimeout(100);
// The target's handle only becomes connectable mid-drag, so pace and dwell.
const from = await centerOf(handle);
const to = await centerOf(target);
await page.mouse.move(from.x, from.y);
await page.mouse.down();
await page.waitForTimeout(50);
const steps = 20;
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.waitForTimeout(12);
}
await page.mouse.move(to.x, to.y);
await page.waitForTimeout(80);
await page.mouse.up();
await page.waitForTimeout(120);
}
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 };
}
export async function createViaCanvasMenu(
page: Page,
action: 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.getByTestId(`cc-canvas-menu-${action}`).click();
}
export async function readDraftCanvas(page: Page): Promise<any | null> {
return await page.evaluate(
() =>
(window as unknown as { __ccDraftCanvas?: unknown }).__ccDraftCanvas ??
null,
);
}
export async function dragNodeOnto(page: Page, node: Locator, target: Locator) {
await mouseDrag(page, await centerOf(node), await centerOf(target));
}
export async function clickContextMenuItem(
page: Page,
node: Locator,
action: string,
) {
await dismissBlockingOverlays(page);
// The menu's items detach when the node underneath it updates, so retry.
await expect(async () => {
await node.click({ button: "right" });
const menu = page.getByTestId("cc-node-context-menu");
await expect(menu).toBeVisible({ timeout: 2000 });
await menu.getByTestId(`cc-menu-${action}`).click({ timeout: 2000 });
}).toPass();
}
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));
}
}
/** Assumes both policy 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();
}
-94
View File
@@ -1,94 +0,0 @@
// Spins up a real NetBird peer in docker, registering via a setup key. The test
// env has no signal/TURN, so the peer registers but stays offline. The client
// MUST reach management directly (http://management:80); caddy breaks h2c.
import { execFileSync } from "child_process";
import { expect, type Page } from "@playwright/test";
import {
createSetupKey,
deletePeersByPrefix,
deleteSetupKeysByPrefix,
listPeers,
} from "./api";
const NETWORK = "environment_netbird";
const IMAGE = "netbirdio/netbird:latest";
const MGMT_URL = "http://management:80";
type RegisteredPeer = {
id: string;
name: string;
hostname: string;
connected: boolean;
};
// argv, not a shell string: the setup key comes back from the management API
// and must never be parsed by a shell.
function docker(args: string[]): string {
return execFileSync("docker", args, { stdio: ["ignore", "pipe", "pipe"] })
.toString()
.trim();
}
// `hostname` doubles as the container name and the peer's account name.
export async function registerDockerPeer(
page: Page,
hostname: string,
autoGroupIds: string[] = [],
): Promise<RegisteredPeer> {
const key = await createSetupKey(page, hostname, autoGroupIds);
return runDockerPeerWithKey(page, hostname, key.key);
}
// Runs a peer with an already-generated setup key instead of minting one.
export async function runDockerPeerWithKey(
page: Page,
hostname: string,
key: string,
): Promise<RegisteredPeer> {
removeDockerContainer(hostname);
docker([
"run",
"-d",
"--name",
hostname,
"--network",
NETWORK,
"--cap-add=NET_ADMIN",
"--cap-add=SYS_ADMIN",
"-e",
`NB_SETUP_KEY=${key}`,
"-e",
`NB_MANAGEMENT_URL=${MGMT_URL}`,
"-e",
`NB_HOSTNAME=${hostname}`,
IMAGE,
]);
let found: RegisteredPeer | undefined;
await expect
.poll(
async () => {
found = ((await listPeers(page)) as RegisteredPeer[]).find(
(p) => p.name === hostname,
);
return !!found;
},
{ timeout: 45_000, intervals: [2000, 2000, 3000] },
)
.toBe(true);
return found!;
}
// Peer and setup-key cleanup is the caller's responsibility.
export function removeDockerContainer(hostname: string) {
try {
docker(["rm", "-f", hostname]);
} catch {
/* none */
}
}
export async function cleanupDockerPeer(page: Page, hostname: string) {
removeDockerContainer(hostname);
await deletePeersByPrefix(page, hostname);
await deleteSetupKeysByPrefix(page, hostname);
}
+24 -22
View File
@@ -5,8 +5,16 @@ export function generateRandomName(prefix?: string): string {
}
/**
* Run an action and wait until every request matching `pattern` has settled,
* plus a quiet window to catch chains where one response triggers the next.
* Run an action (click, goto, ...) and wait until every API request whose
* URL contains `pattern` has finished (response received or failed), plus a
* short quiet window to catch request chains where one response triggers
* the next fetch.
*
* Use this to make navigation deterministic: e.g. when opening the services
* page, the table only renders fully after /api/reverse-proxies/* calls
* return, so asserting on rows right after the click races the backend.
*
* Returns whatever the action returns.
*/
export async function waitForApiCalls<T>(
page: Page,
@@ -41,6 +49,8 @@ export async function waitForApiCalls<T>(
try {
const result = await action();
const deadline = Date.now() + timeoutMs;
// Wait until: at least one matching request was seen (unless none ever
// fires), none are in flight, and the network has been quiet for quietMs.
while (Date.now() < deadline) {
const quietFor = Date.now() - lastActivity;
if (inFlight === 0 && quietFor >= quietMs) {
@@ -56,6 +66,11 @@ export async function waitForApiCalls<T>(
}
}
/**
* Apply a single-choice (radio) table filter via the new TableFilters UI:
* open the "Filters" popover, pick the filter by column id, then select the
* option by its visible label (e.g. "Active", "Inactive", "All").
*/
export async function applyRadioTableFilter(
page: Page,
filterId: string,
@@ -70,32 +85,19 @@ export async function applyRadioTableFilter(
}
/**
* Call before navigating. The override persists for the whole browser context
* and the page is shared per worker, so edition-dependent specs must set it.
*/
export async function setTestEdition(
page: Page,
edition: "cloud" | "licensed" | "oss",
) {
await page.evaluate((ed) => {
try {
window.localStorage.setItem("netbird-test-edition", ed);
} catch (e) {
/* storage unavailable */
}
}, edition);
}
/**
* Some Radix modals leave scroll-lock, `pointer-events: none` or a stale
* overlay div behind, blocking the entire page.
* Clear stale Radix scroll-lock and overlay from body.
* Some Radix modals leave `data-scroll-locked`, `pointer-events: none`,
* or a stale overlay div blocking the entire page.
*/
export async function clearScrollLock(page: Page) {
await page.evaluate(() => {
document.body.removeAttribute("data-scroll-locked");
document.body.style.removeProperty("pointer-events");
// Remove stale Radix dialog overlays that block pointer events
document
.querySelectorAll('div[data-state="open"].fixed[class*="backdrop-blur"]')
.querySelectorAll(
'div[data-state="open"].fixed[class*="backdrop-blur"]',
)
.forEach((el) => el.remove());
});
}
@@ -1,156 +0,0 @@
/**
* Agent Network agentgateway provider spec.
*
* Exercises the catalog-driven provider flow against a management build that
* includes the agentgateway catalog entry. Older builds skip the test.
*/
import {
deleteAgentNetworkProvidersByPrefix,
listAgentNetworkCatalog,
supportsAgentNetworkSettingsBootstrap,
} from "../helpers/api";
import { navigateTo } from "../helpers/auth";
import { expect, test } from "../helpers/fixtures";
import { generateRandomName } from "../helpers/utils";
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
const AGENTGATEWAY_CATALOG_ID = "agentgateway";
const PROVIDER_PREFIX = "e2e-agentgateway-";
test.describe
.serial("Agent Network agentgateway provider @agent-network", () => {
test("connect agentgateway with its trusted identity mapping", async ({
dashboardAsOwner: page,
}) => {
await page.addInitScript(
([key, value]) => {
try {
window.localStorage.setItem(key as string, value as string);
} catch {}
},
[AGENT_NETWORK_CONFIG_KEY, "enabled"],
);
const catalog = await listAgentNetworkCatalog(page);
test.skip(
!catalog.some((entry) => entry.id === AGENTGATEWAY_CATALOG_ID),
`management catalog has no ${AGENTGATEWAY_CATALOG_ID} entry`,
);
test.skip(
!(await supportsAgentNetworkSettingsBootstrap(page)),
"management build does not support Agent Network settings bootstrap",
);
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
try {
await navigateTo(page, "/agent-network/providers");
await page
.getByTestId("connect-agent-network-provider")
.first()
.click({ force: true });
await page
.getByTestId("agent-network-provider-type")
.click({ force: true });
await page
.getByTestId("select-dropdown-search")
.fill(AGENTGATEWAY_CATALOG_ID);
await page
.getByTestId("agent-network-provider-option-agentgateway")
.click({ force: true });
// Saving a provider makes management call the upstream, and it refuses
// the save with a 422 unless the upstream answers a model listing — so a
// made-up host cannot be used here. agentgateway is self-hosted and has
// no public endpoint to point at, so create-test-env.sh runs a stub that
// answers that probe inside the compose network.
const upstreamURL = "http://agentgateway-stub:8088";
await page
.getByTestId("agent-network-provider-upstream-url")
.fill(upstreamURL);
await page
.getByTestId("agent-network-provider-api-key")
.fill("e2e-agentgateway-virtual-key");
const providerName = generateRandomName(PROVIDER_PREFIX);
await page.getByTestId("agent-network-provider-name").fill(providerName);
await page
.getByTestId("agent-network-provider-models-tab")
.click({ force: true });
await expect(
page.getByTestId("agent-network-provider-models-help"),
).toContainText("Empty = all catalog models");
await page
.getByTestId("agent-network-provider-continue")
.click({ force: true });
await expect(
page.getByTestId("agent-network-provider-mappings-tab"),
).toHaveAttribute("data-state", "active");
await expect(
page.getByTestId("agent-network-provider-user-mapping"),
).toContainText("x-netbird-user-id");
await expect(
page.getByTestId("agent-network-provider-groups-mapping"),
).toContainText("x-netbird-groups");
await expect(
page.getByTestId("agent-network-provider-groups-guidance"),
).toContainText("must not be used as an agentgateway authorization claim");
const createResponse = page.waitForResponse(
(response) =>
response.url().includes("/agent-network/providers") &&
response.request().method() === "POST",
{ timeout: 30_000 },
);
await page.getByTestId("agent-network-provider-submit").click({
force: true,
});
const created = await createResponse;
expect([200, 201]).toContain(created.status());
const body = created.request().postDataJSON();
expect(body).toMatchObject({
provider_id: AGENTGATEWAY_CATALOG_ID,
name: providerName,
upstream_url: upstreamURL,
models: [],
metadata_disabled: false,
});
expect(body).not.toHaveProperty("identity_header_user_id");
expect(body).not.toHaveProperty("identity_header_groups");
await expect(
page.getByTestId("agent-network-provider-modal"),
).toBeHidden();
await expect(page.getByTestId(providerName)).toBeVisible();
await expect(
page.getByTestId(`provider-models-${providerName}`),
).toHaveText("All Models");
await page.getByTestId(providerName).click({ force: true });
await expect(
page.getByTestId("agent-network-provider-modal"),
).toBeVisible();
await expect(
page.getByTestId("agent-network-provider-upstream-url"),
).toHaveValue(upstreamURL);
await page
.getByTestId("agent-network-provider-mappings-tab")
.click({ force: true });
await expect(
page.getByTestId("agent-network-provider-user-mapping"),
).toContainText("x-netbird-user-id");
await expect(
page.getByTestId("agent-network-provider-groups-mapping"),
).toContainText("x-netbird-groups");
await page
.getByTestId("agent-network-provider-modal")
.getByTestId("modal-close")
.click({ force: true });
} finally {
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
}
});
});
@@ -1,213 +0,0 @@
/**
* 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();
}
});
});
+19 -120
View File
@@ -3,73 +3,32 @@
*
* 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 on the
* Connect Agent page (Kimi CLI tab, Kimi backend option in the Claude Code
* tab) that only render when a Kimi provider is connected.
* 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 same applies
* to the explicit settings bootstrap: connecting the first provider of an
* account POSTs /agent-network/settings first, so on a build without that
* endpoint the wizard cannot get as far as creating a provider.
* 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 { type Browser, expect, type Page,test } from "@playwright/test";
import {
createAgentNetworkPolicy,
createGroup,
deleteAgentNetworkPoliciesByPrefix,
deleteAgentNetworkProvidersByPrefix,
deleteGroup,
getCurrentUser,
listAgentNetworkCatalog,
listGroups,
supportsAgentNetworkAgentConfig,
supportsAgentNetworkSettingsBootstrap,
updateUserAutoGroups,
} from "../helpers/api";
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-";
/**
* Remove every fixture this spec creates: the policy granting the caller the
* provider, the group carrying that grant (detached from the caller first —
* a group referenced by a user's auto-groups refuses deletion), and the
* provider itself. Runs before the test too, so leftovers from an interrupted
* run never poison the next one.
*/
async function cleanupKimiFixtures(page: Page) {
await deleteAgentNetworkPoliciesByPrefix(page, PROVIDER_PREFIX);
const fixtureGroups = (await listGroups(page)).filter((g) =>
g.name.startsWith(PROVIDER_PREFIX),
);
if (fixtureGroups.length > 0) {
const fixtureGroupIds = new Set(fixtureGroups.map((g) => g.id));
const caller = await getCurrentUser(page);
if ((caller.auto_groups ?? []).some((id) => fixtureGroupIds.has(id))) {
await updateUserAutoGroups(
page,
caller,
(caller.auto_groups ?? []).filter((id) => !fixtureGroupIds.has(id)),
);
}
for (const g of fixtureGroups) {
await deleteGroup(page, g.id);
}
}
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
}
async function newAgentNetworkPage(browser: Browser): Promise<{
page: Page;
close: () => Promise<void>;
@@ -103,22 +62,8 @@ test.describe.serial("Agent Network Kimi provider @agent-network", () => {
!catalog.some((c) => c.id === KIMI_CATALOG_ID),
`management catalog has no ${KIMI_CATALOG_ID} entry`,
);
test.skip(
!(await supportsAgentNetworkSettingsBootstrap(page)),
"management build has no POST /agent-network/settings, so the " +
"wizard cannot bootstrap the account before the first create",
);
// The Kimi-gated config surfaces live on the Connect Agent page, whose
// caller-scoped GET /agent-network/agent-config answer ships with
// newer management builds. The suite starts running once the backend
// under test carries it.
test.skip(
!(await supportsAgentNetworkAgentConfig(page)),
"management build has no GET /agent-network/agent-config, so the " +
"Connect Agent page cannot render the agent config",
);
await cleanupKimiFixtures(page);
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
await page.goto("/agent-network/providers");
await page.keyboard.press("Escape");
@@ -165,70 +110,22 @@ test.describe.serial("Agent Network Kimi provider @agent-network", () => {
resp.request().method() === "POST",
{ timeout: 30_000 },
);
// On the account's first provider the wizard bootstraps the settings row
// first and only creates the provider once that succeeds. A rejected
// bootstrap therefore means no provider POST ever happens, which on its
// own surfaces only as the whole test timing out — race it so the real
// status is reported instead. 409 is not a rejection: it means a
// concurrent bootstrap won and the row exists, which the wizard treats as
// success and follows with the provider create, so matching it here would
// fail a run that is about to succeed.
const bootstrapRejected: Promise<never> = page
.waitForResponse(
(resp) =>
resp.url().includes("/agent-network/settings") &&
resp.request().method() === "POST" &&
resp.status() !== 409 &&
!resp.ok(),
{ timeout: 30_000 },
)
.then(
(resp) => {
throw new Error(
`Agent Network settings bootstrap POST returned ${resp.status()}; ` +
"the provider was never created",
);
},
// Nothing matched: the bootstrap succeeded or wasn't needed. Never
// settle, leaving the race to the provider create.
() => new Promise<never>(() => {}),
);
await page
.getByRole("button", { name: "Connect Provider" })
.last()
.click({ force: true });
const created = await Promise.race([createResponse, bootstrapRejected]);
expect([200, 201]).toContain(created.status());
const createdProvider = (await created.json()) as { id: string };
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 ----
// The agent config lives inline on the Connect Agent page — the
// providers page keeps only the endpoint URL and Copy. That page's
// answer is caller-scoped: it offers only providers the caller's own
// policies authorize, so grant the caller the new provider through a
// dedicated group + policy (removed again by cleanupKimiFixtures).
const caller = await getCurrentUser(page);
const grantGroup = await createGroup(page, `${PROVIDER_PREFIX}grant`);
await updateUserAutoGroups(page, caller, [
...(caller.auto_groups ?? []),
grantGroup.id,
]);
await createAgentNetworkPolicy(page, {
name: `${PROVIDER_PREFIX}policy`,
source_groups: [grantGroup.id],
destination_provider_ids: [createdProvider.id],
});
await page
.getByRole("button", { name: "Agent Config" })
.click({ force: true });
await page.goto("/agent-network/connect");
// Kimi CLI tab only renders when a kimi_api provider is connected. The
// longer timeout covers the page's caller-scoped agent-config fetch.
await expect(page.getByRole("tab", { name: "Kimi CLI" })).toBeVisible({
timeout: 15_000,
});
// 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
@@ -257,8 +154,10 @@ test.describe.serial("Agent Network Kimi provider @agent-network", () => {
await expect(page.getByText('wire_api = "responses"')).toBeVisible();
await expect(page.getByText('wire_api = "chat"')).not.toBeVisible();
await page.keyboard.press("Escape");
// ---- cleanup ----
await cleanupKimiFixtures(page);
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
} finally {
await close();
}
@@ -1,195 +0,0 @@
/**
* Agent Network provider save: what the operator sees when the backend refuses.
*
* Saving a provider checks its upstream URL and credential against the vendor
* before storing anything, so a 422 naming the field to correct is an ordinary
* outcome of the form rather than a server fault. Four things have to hold,
* and each one has been wrong at some point:
*
* - while the check runs the submit says so and refuses a second click. The
* vendor call is the slow part and an unreachable upstream drags it out to
* a timeout, which looked exactly like a button that had not registered
* the first click.
* - exactly ONE toast, the shared "Request failed with status code N", which
* quotes the API's own sentence. A second toast from the save path said the
* same thing under a vaguer title.
* - the toast is styled as a failure. notify() renders green with a check
* mark unless told otherwise, so a refusal announced itself as a success.
* - the modal stays open with the typed values intact. The API never returns
* an API key, so closing the form loses it with nowhere to retype it.
*
* The refusal is mocked rather than provoked: the backend check ships with a
* management build these tests do not pin, and what is under test here is the
* dashboard's handling of the response, not the vendor call that produces it.
*
* Like the other agent-network specs, this one builds its own context rather
* than taking the shared dashboardAsOwner fixture: the Agent Network menu is
* deployment-gated behind a localStorage override that has to be in place
* before the first navigation, which addInitScript on an own context is the
* way to do. The route interception below is a second reason — the shared page
* is worker-scoped, and a 422 left on it would follow every later test.
*/
import { type Browser, expect, type Page, test } from "@playwright/test";
import { loginToApp, navigateTo } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
const PROVIDERS_ENDPOINT = /\/api\/agent-network\/providers(\?|$)/;
const PROVIDER_PREFIX = "e2e-refused-";
const TITLE_TESTID = "notification-title";
// The message a refused save carries: the backend names which of the two
// fields is at fault, without a status code and without echoing the URL.
const REFUSAL = "the upstream url could not be reached: no such host";
// Matched case-insensitively on purpose. The backend lowercases its messages
// (WriteError does, and the copy is written for it) while the toast uppercases
// the first character before rendering. Asserting either spelling would pin
// the test to that transform rather than to the sentence the operator reads.
const REFUSAL_TEXT = new RegExp(
REFUSAL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
"i",
);
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() };
}
// How long the mocked create takes to answer. The real check is a vendor round
// trip, so a save is never instant; holding the response gives the in-flight
// assertions a window to run in without racing the toast.
const REFUSAL_DELAY_MS = 1500;
// refuseProviderCreate answers the create with the 422 the credential check
// produces. Only POST is intercepted: the page still lists providers, and the
// settings bootstrap that may precede the create is left alone.
async function refuseProviderCreate(page: Page) {
await page.route(PROVIDERS_ENDPOINT, async (route) => {
if (route.request().method() !== "POST") return route.continue();
const origin = route.request().headers()["origin"] || "*";
await new Promise((resolve) => setTimeout(resolve, REFUSAL_DELAY_MS));
await route.fulfill({
status: 422,
headers: {
"content-type": "application/json",
"access-control-allow-origin": origin,
},
body: JSON.stringify({ code: 422, message: REFUSAL }),
});
});
}
test.describe
.serial("Agent Network refused provider save @agent-network", () => {
test("reports the refusal once and keeps the form open", async ({
browser,
}) => {
const { page, close } = await newAgentNetworkPage(browser);
try {
await refuseProviderCreate(page);
// navigateTo rather than goto: it dismisses the setup modal that greets a
// fresh account and clears the scroll lock it leaves behind, either of
// which swallows the clicks below.
await navigateTo(page, "/agent-network/providers");
await page
.getByRole("button", { name: "Connect Provider" })
.first()
.click({ force: true });
const providerName = generateRandomName(PROVIDER_PREFIX);
await page
.getByTestId("agent-network-provider-name")
.fill(providerName);
await page
.getByTestId("agent-network-provider-api-key")
.fill("sk-e2e-refused-key");
// The submit lives on the Models tab — the Provider tab's primary button
// only advances to it.
await page.getByRole("tab", { name: "Models" }).click({ force: true });
const submit = page.getByTestId("agent-network-provider-submit");
await expect(submit).toBeEnabled();
const refusal = page.waitForResponse(
(resp) =>
PROVIDERS_ENDPOINT.test(resp.url()) &&
resp.request().method() === "POST",
{ timeout: 30_000 },
);
await submit.click({ force: true });
// ---- the wait is visible while it lasts ----
// A second create is not idempotent, so the button has to say it is
// working and stop taking clicks rather than sit there looking untouched.
// Asserted before the response is awaited: the mock holds it open for
// exactly this window.
await expect(submit).toContainText("Connecting provider");
await expect(submit).toBeDisabled();
expect((await refusal).status()).toBe(422);
// ---- the toast says what the API said ----
const title = page.getByTestId(TITLE_TESTID).first();
await expect(title).toContainText("Request failed with status code 422");
await expect(
page.locator("[data-toast-notification]").first(),
).toContainText(REFUSAL_TEXT);
// ---- and only that toast ----
// The save path used to add its own on top, so the count is the
// assertion rather than the presence of the right one.
await expect(page.locator("[data-toast-notification]")).toHaveCount(1);
await expect(page.getByText("Failed to connect provider")).toHaveCount(0);
// ---- styled as a failure, not a success ----
// notify() paints the icon tile green with a check unless the caller
// says otherwise, which is how a refusal once looked like a success.
//
// A failure reaches the tile two ways — notify()'s own error state, and
// a caller passing its own colour and icon, which is what the shared
// request-failed toast does — so what has to hold is the negative: this
// is not the default success tile.
const icon = page.getByTestId("notification-icon");
await expect(icon).toBeVisible();
await expect(icon).not.toHaveAttribute("data-variant", "success");
// ---- the form is still there, still holding what was typed ----
// The submit only exists while the modal is open, so its presence is the
// check that nothing closed underneath the toast. It also has to come
// back out of the in-flight state, or the retry the toast asks for is
// impossible.
await expect(submit).toBeVisible();
await expect(submit).toBeEnabled();
await expect(submit).toContainText("Connect Provider");
await page.getByRole("tab", { name: "Provider" }).click({ force: true });
await expect(
page.getByTestId("agent-network-provider-name"),
).toHaveValue(providerName);
// The key matters most: the API never returns one, so a form that lost
// it leaves the operator with nothing to correct.
await expect(
page.getByTestId("agent-network-provider-api-key"),
).toHaveValue("sk-e2e-refused-key");
} finally {
await close();
}
});
});
Loaded 20 of 341 files, more files were not shown because too many files have changed in this diff. Show more