mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b786c3395a | ||
|
|
3c009ddc0d | ||
|
|
3c72444e83 | ||
|
|
386fb12013 | ||
|
|
e3cc74522c | ||
|
|
1537d31f0d | ||
|
|
4f905a4368 | ||
|
|
5cf8995c65 | ||
|
|
ca16b4868b | ||
|
|
d3ff3bafca | ||
|
|
4ca8cc09a4 | ||
|
|
9e6d512ed1 | ||
|
|
7b60d87652 | ||
|
|
d72f417ff3 | ||
|
|
3be6dda6a7 | ||
|
|
98ae943bbc | ||
|
|
99f86b6421 | ||
|
|
0ceda40180 | ||
|
|
f4cf7458be | ||
|
|
9e1c29b769 | ||
|
|
fb05f2c172 | ||
|
|
6cf828ce8a | ||
|
|
83cb07a44c | ||
|
|
bbfa2d3a79 | ||
|
|
a92816b9b8 | ||
|
|
0caf754897 | ||
|
|
49582fff74 | ||
|
|
af7745a03b | ||
|
|
19d82cc2ab | ||
|
|
b66cc4ccb7 | ||
|
|
1d408f2e7e | ||
|
|
9fab9d7a83 |
No files matched your search
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals","prettier"],
|
||||
"plugins": ["simple-import-sort"],
|
||||
"rules": {
|
||||
"simple-import-sort/imports": [
|
||||
"warn",
|
||||
{
|
||||
"groups": [["^\\u0000", "^@?\\w", "^[^.]", "^\\."]]
|
||||
}
|
||||
],
|
||||
"simple-import-sort/exports": "warn"
|
||||
}
|
||||
}
|
||||
@@ -99,12 +99,14 @@ 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
|
||||
@@ -116,11 +118,12 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
push: true
|
||||
push: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
platforms: linux/amd64,linux/arm64,linux/arm
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- run: |
|
||||
- if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
run: |
|
||||
echo '### Pushed tags' >> $GITHUB_STEP_SUMMARY
|
||||
echo '${{ steps.meta.outputs.tags }}' >> $GITHUB_STEP_SUMMARY
|
||||
@@ -0,0 +1,81 @@
|
||||
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}."
|
||||
@@ -101,17 +101,61 @@ jobs:
|
||||
echo "Using reverse-proxy tag: $TAG"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup test environment
|
||||
- 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
|
||||
env:
|
||||
MANAGEMENT_IMAGE_TAG: ${{ steps.management_tag.outputs.tag }}
|
||||
REVERSE_PROXY_IMAGE_TAG: ${{ steps.reverse_proxy_tag.outputs.tag }}
|
||||
run: cd ./e2e/environment && bash create-test-env.sh
|
||||
# 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
|
||||
|
||||
- name: Run Playwright tests
|
||||
id: playwright
|
||||
run: |
|
||||
set -o pipefail
|
||||
npm run test:ci 2>&1 | tee playwright-output.log
|
||||
npx playwright test --config=e2e/playwright.config.ts 2>&1 | tee playwright-output.log
|
||||
|
||||
- name: Append Playwright summary to job summary
|
||||
if: always() && hashFiles('e2e/test-results/results.json') != ''
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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
|
||||
@@ -44,6 +44,7 @@ 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
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.14
|
||||
FROM alpine:3.24
|
||||
|
||||
RUN apk add --no-cache bash curl less ca-certificates git tzdata zip gettext \
|
||||
nginx curl supervisor certbot-nginx && \
|
||||
|
||||
@@ -218,6 +218,7 @@ 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
|
||||
|
||||
|
||||
@@ -9,4 +9,6 @@ proxy-no-ports.env
|
||||
proxy-certs/
|
||||
proxy-certs-no-ports/
|
||||
docker-compose.yml
|
||||
/machinekey
|
||||
/machinekey
|
||||
geo-cache/
|
||||
geo-dump/
|
||||
@@ -61,7 +61,13 @@ wait_proxy_cluster() {
|
||||
SERVICE_NAME=${1:-reverse-proxy}
|
||||
echo -n "Waiting for $SERVICE_NAME to register with management "
|
||||
set +e
|
||||
local attempts=60
|
||||
# 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 i
|
||||
for ((i = 1; i <= attempts; i++)); do
|
||||
if $DOCKER_COMPOSE_COMMAND logs "$SERVICE_NAME" 2>&1 | grep -q "Initial mapping sync complete"; then
|
||||
@@ -589,6 +595,8 @@ 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
|
||||
|
||||
@@ -760,7 +768,41 @@ 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:
|
||||
@@ -794,7 +836,7 @@ services:
|
||||
- NB_TRAFFIC_FLOW_INTERVAL=20s
|
||||
- NB_SINGLE_INSTANCE_MODE=true
|
||||
volumes:
|
||||
- netbird_management:/var/lib/netbird
|
||||
- netbird_management:/var/lib/netbird${geo_mounts}
|
||||
- ./management.json:/etc/netbird/management.json
|
||||
command: [
|
||||
"--port", "80",
|
||||
@@ -845,6 +887,11 @@ 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: [
|
||||
@@ -866,6 +913,9 @@ 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
|
||||
|
||||
+269
-82
File diff suppressed because it is too large.
Load diff
+9
-1
@@ -117,7 +117,15 @@ export async function loginToApp(
|
||||
* Use this instead of page.goto() for in-app navigation after loginToApp().
|
||||
*/
|
||||
export async function navigateTo(page: Page, path: string) {
|
||||
await page.goto(path, { waitUntil: "domcontentloaded" });
|
||||
// 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" });
|
||||
}
|
||||
const modal = page.getByTestId("setup-netbird-modal");
|
||||
try {
|
||||
await modal.waitFor({ state: "visible", timeout: 3_000 });
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
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);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// 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);
|
||||
}
|
||||
+22
-24
@@ -5,16 +5,8 @@ export function generateRandomName(prefix?: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export async function waitForApiCalls<T>(
|
||||
page: Page,
|
||||
@@ -49,8 +41,6 @@ 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) {
|
||||
@@ -66,11 +56,6 @@ 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,
|
||||
@@ -85,19 +70,32 @@ export async function applyRadioTableFilter(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
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());
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 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 });
|
||||
|
||||
const upstreamURL = "https://agentgateway.e2e.example";
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,32 +3,73 @@
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* 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 { 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 { 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>;
|
||||
@@ -62,8 +103,22 @@ 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 deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
|
||||
await cleanupKimiFixtures(page);
|
||||
|
||||
await page.goto("/agent-network/providers");
|
||||
await page.keyboard.press("Escape");
|
||||
@@ -110,22 +165,70 @@ 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 });
|
||||
expect([200, 201]).toContain((await createResponse).status());
|
||||
const created = await Promise.race([createResponse, bootstrapRejected]);
|
||||
expect([200, 201]).toContain(created.status());
|
||||
const createdProvider = (await created.json()) as { id: string };
|
||||
|
||||
// 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 });
|
||||
// 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],
|
||||
});
|
||||
|
||||
// Kimi CLI tab only renders when a kimi_api provider is connected.
|
||||
await expect(page.getByRole("tab", { name: "Kimi CLI" })).toBeVisible();
|
||||
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,
|
||||
});
|
||||
|
||||
// Claude Code tab's backend dropdown offers (and, with Kimi as the only
|
||||
// Anthropic-shaped provider, pre-selects) Kimi — its settings.json
|
||||
@@ -154,10 +257,8 @@ 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 deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
|
||||
await cleanupKimiFixtures(page);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Agent Network settings wire-shape spec.
|
||||
*
|
||||
* The management API signals "account not bootstrapped" differently by
|
||||
* generation: current servers answer GET /agent-network/settings with the
|
||||
* defaults object carrying an empty endpoint/proxy_address, older ones with
|
||||
* 200 + a JSON null body, and the oldest with a 404.
|
||||
* useAgentNetworkSettings normalizes all three to the same null-settings
|
||||
* signal, and this spec pins that: the providers page must render the
|
||||
* connect-first endpoint placeholder for every unbootstrapped shape, and the
|
||||
* endpoint badge once the account is bootstrapped. The settings route is
|
||||
* mocked per test, so the assertions do not depend on the backend build or
|
||||
* on account state left behind by other suites.
|
||||
*/
|
||||
import { test, expect, type Browser, type Page } from "@playwright/test";
|
||||
import { loginToApp } from "../helpers/auth";
|
||||
|
||||
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
|
||||
|
||||
const EMPTY_STATE_TEXT =
|
||||
"Connect your first provider to set up your agent network endpoint.";
|
||||
|
||||
const BOOTSTRAPPED_ENDPOINT = "violet.eu.proxy.netbird.io";
|
||||
|
||||
// The defaults object current servers return for an account that has not been
|
||||
// bootstrapped yet: the mutable settings carry their default values, the
|
||||
// endpoint/proxy_address identity is empty, and there are no timestamps because
|
||||
// no row has been persisted.
|
||||
const UNBOOTSTRAPPED_DEFAULTS = {
|
||||
endpoint: "",
|
||||
proxy_address: "",
|
||||
dedicated: false,
|
||||
enable_log_collection: true,
|
||||
enable_prompt_collection: false,
|
||||
redact_pii: false,
|
||||
access_log_retention_days: 30,
|
||||
};
|
||||
|
||||
const BOOTSTRAPPED_SETTINGS = {
|
||||
endpoint: BOOTSTRAPPED_ENDPOINT,
|
||||
proxy_address: "eu.proxy.netbird.io",
|
||||
dedicated: false,
|
||||
enable_log_collection: true,
|
||||
enable_prompt_collection: false,
|
||||
redact_pii: false,
|
||||
access_log_retention_days: 30,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
async function mockSettingsResponse(
|
||||
page: Page,
|
||||
response: { status: number; body: string },
|
||||
) {
|
||||
await page.route("**/api/agent-network/settings", (route) => {
|
||||
if (route.request().method() !== "GET") return route.continue();
|
||||
return route.fulfill({
|
||||
status: response.status,
|
||||
contentType: "application/json",
|
||||
body: response.body,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openProvidersPage(
|
||||
browser: Browser,
|
||||
settingsResponse: { status: number; body: string },
|
||||
): Promise<{ page: Page; close: () => Promise<void> }> {
|
||||
const context = await browser.newContext({
|
||||
storageState: "e2e/fixtures/auth/owner.json",
|
||||
});
|
||||
// Close the context on any setup failure — the caller only receives the
|
||||
// close callback once setup succeeds.
|
||||
try {
|
||||
await context.addInitScript(
|
||||
([key, value]) => {
|
||||
try {
|
||||
window.localStorage.setItem(key as string, value as string);
|
||||
} catch (e) {}
|
||||
},
|
||||
[AGENT_NETWORK_CONFIG_KEY, "enabled"],
|
||||
);
|
||||
const page = await context.newPage();
|
||||
await mockSettingsResponse(page, settingsResponse);
|
||||
await loginToApp(page, "owner");
|
||||
await page.goto("/agent-network/providers");
|
||||
await page.keyboard.press("Escape");
|
||||
return { page, close: () => context.close() };
|
||||
} catch (e) {
|
||||
await context.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const UNBOOTSTRAPPED_SHAPES: {
|
||||
name: string;
|
||||
response: { status: number; body: string };
|
||||
}[] = [
|
||||
{
|
||||
name: "defaults object with empty endpoint (current servers)",
|
||||
response: { status: 200, body: JSON.stringify(UNBOOTSTRAPPED_DEFAULTS) },
|
||||
},
|
||||
{
|
||||
name: "200 with JSON null body (older servers)",
|
||||
response: { status: 200, body: "null" },
|
||||
},
|
||||
{
|
||||
name: "404 (oldest servers)",
|
||||
response: {
|
||||
status: 404,
|
||||
body: JSON.stringify({ message: "settings not found", code: 404 }),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
test.describe("Agent Network settings wire shapes @agent-network", () => {
|
||||
for (const shape of UNBOOTSTRAPPED_SHAPES) {
|
||||
test(`unbootstrapped account renders the empty state: ${shape.name}`, async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openProvidersPage(browser, shape.response);
|
||||
try {
|
||||
await expect(page.getByText(EMPTY_STATE_TEXT)).toBeVisible();
|
||||
await expect(page.getByText(BOOTSTRAPPED_ENDPOINT)).toHaveCount(0);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("bootstrapped account renders the endpoint instead of the empty state", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openProvidersPage(browser, {
|
||||
status: 200,
|
||||
body: JSON.stringify(BOOTSTRAPPED_SETTINGS),
|
||||
});
|
||||
try {
|
||||
await expect(page.getByText(BOOTSTRAPPED_ENDPOINT)).toBeVisible();
|
||||
await expect(page.getByText(EMPTY_STATE_TEXT)).toHaveCount(0);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { expect, type Response } from "@playwright/test";
|
||||
import { test } from "../helpers/fixtures";
|
||||
import { deleteGroup, deletePolicyById, listPolicies } from "../helpers/api";
|
||||
import {
|
||||
canvasNode,
|
||||
connectNodes,
|
||||
createViaCanvasMenu,
|
||||
enterDraft,
|
||||
expectChangeCount,
|
||||
resetDraftState,
|
||||
reviewButton,
|
||||
} from "../helpers/control-center";
|
||||
|
||||
test.describe.serial("Control Center Deploy @control-center", () => {
|
||||
test.beforeEach(async ({ dashboardAsOwner: page }) => {
|
||||
await resetDraftState(page);
|
||||
});
|
||||
|
||||
test("Deploys a group+group+policy draft to the account in dependency order", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
await enterDraft(page);
|
||||
|
||||
await createViaCanvasMenu(page, "new-group", { fx: 0.3, fy: 0.4 });
|
||||
await createViaCanvasMenu(page, "new-group", { fx: 0.3, fy: 0.7 });
|
||||
const groups = canvasNode(page, "group-new-");
|
||||
await expect(groups).toHaveCount(2);
|
||||
|
||||
await createViaCanvasMenu(page, "new-policy", { fx: 0.65, fy: 0.5 });
|
||||
const policy = canvasNode(page, "policy-new-");
|
||||
await expect(policy).toHaveCount(1);
|
||||
|
||||
// g0 as source, g1 as destination → a complete, trackable policy.
|
||||
await connectNodes(page, groups.nth(0), policy, "sr");
|
||||
await connectNodes(page, groups.nth(1), policy, "sl");
|
||||
await expectChangeCount(page, 3);
|
||||
|
||||
// The canvas assigns generic names, so capture the ids for exact cleanup.
|
||||
const createdGroupIds: string[] = [];
|
||||
const createdPolicyIds: string[] = [];
|
||||
const collectCreatedIds = async (resp: Response) => {
|
||||
if (resp.request().method() !== "POST") return;
|
||||
const url = resp.url();
|
||||
if (url.includes("/api/groups")) {
|
||||
const b = await resp.json().catch(() => null);
|
||||
if (b?.id) createdGroupIds.push(b.id);
|
||||
} else if (url.includes("/api/policies")) {
|
||||
const b = await resp.json().catch(() => null);
|
||||
if (b?.id) createdPolicyIds.push(b.id);
|
||||
}
|
||||
};
|
||||
page.on("response", collectCreatedIds);
|
||||
|
||||
await reviewButton(page).click();
|
||||
await expect(page.getByTestId("cc-deploy")).toBeVisible();
|
||||
await page.getByTestId("cc-deploy").click({ force: true });
|
||||
|
||||
// Deploy finishes by exiting the draft back to live.
|
||||
await expect(page.getByTestId("cc-toolbar-add")).not.toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
try {
|
||||
await expect.poll(() => createdGroupIds.length).toBe(2);
|
||||
await expect.poll(() => createdPolicyIds.length).toBe(1);
|
||||
|
||||
// Real group ids in the deployed policy prove the groups were created
|
||||
// before it.
|
||||
const policies = await listPolicies(page);
|
||||
const deployed = policies.find((p) => p.id === createdPolicyIds[0]);
|
||||
expect(deployed).toBeTruthy();
|
||||
const rule = deployed!.rules[0];
|
||||
const refIds = [
|
||||
...(rule.sources ?? []),
|
||||
...(rule.destinations ?? []),
|
||||
].map((x: any) => (typeof x === "string" ? x : x?.id));
|
||||
for (const gid of createdGroupIds) expect(refIds).toContain(gid);
|
||||
} finally {
|
||||
// The owner page is worker-scoped, so an attached listener would keep
|
||||
// reading response bodies for every later test on this worker.
|
||||
page.off("response", collectCreatedIds);
|
||||
// Policy first: a group in use can't be deleted.
|
||||
for (const pid of createdPolicyIds) await deletePolicyById(page, pid);
|
||||
for (const gid of createdGroupIds) await deleteGroup(page, gid);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loaded 20 of 329 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user