mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca7ae3f924 | ||
|
|
1537d31f0d | ||
|
|
4f905a4368 | ||
|
|
5cf8995c65 | ||
|
|
ca16b4868b | ||
|
|
d3ff3bafca | ||
|
|
4ca8cc09a4 | ||
|
|
9e6d512ed1 | ||
|
|
7b60d87652 | ||
|
|
d72f417ff3 | ||
|
|
3be6dda6a7 | ||
|
|
98ae943bbc | ||
|
|
99f86b6421 | ||
|
|
0ceda40180 | ||
|
|
f4cf7458be |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+255
-93
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,196 @@
|
||||
/**
|
||||
* Agent Network bootstrap-cluster spec.
|
||||
*
|
||||
* The account's agent network endpoint is assigned once, from the proxy
|
||||
* cluster the connect-provider wizard pins it to, and it is immutable after
|
||||
* that. Not every live cluster can host it: the endpoint is a private service,
|
||||
* reachable only from connected peers and authorised by their tunnel identity,
|
||||
* which only a cluster with a connected embedded proxy (`netbird proxy`) can
|
||||
* serve. Management reports that per cluster as supports_private and refuses a
|
||||
* bootstrap onto a cluster without it.
|
||||
*
|
||||
* This spec pins the wizard's half of that contract: it picks a
|
||||
* private-capable cluster even when a centralised one is listed first, and it
|
||||
* blocks setup — with a message naming what is missing — when no listed
|
||||
* cluster qualifies. Both the settings and domains routes are mocked, so the
|
||||
* assertions do not depend on which clusters the test backend happens to have
|
||||
* connected, and no provider is created against it.
|
||||
*/
|
||||
import { type Browser, expect, type Page, test } from "@playwright/test";
|
||||
import { loginToApp } from "../helpers/auth";
|
||||
|
||||
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
|
||||
|
||||
// Listed first, and unusable: a centralised proxy cluster. Picking it is the
|
||||
// bug this spec guards against.
|
||||
const CENTRAL_CLUSTER = "central.proxy.netbird.io";
|
||||
// Listed second, and the only valid pick.
|
||||
const EMBEDDED_CLUSTER = "embedded.proxy.netbird.io";
|
||||
|
||||
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 = {
|
||||
...UNBOOTSTRAPPED_DEFAULTS,
|
||||
endpoint: `violet.${EMBEDDED_CLUSTER}`,
|
||||
proxy_address: EMBEDDED_CLUSTER,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
/** A free (cluster-backed) domain as /reverse-proxies/domains reports it. */
|
||||
function freeDomain(name: string, supportsPrivate: boolean) {
|
||||
return {
|
||||
id: name,
|
||||
domain: name,
|
||||
validated: true,
|
||||
type: "free",
|
||||
supports_private: supportsPrivate,
|
||||
supports_custom_ports: true,
|
||||
require_subdomain: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function json(route: import("@playwright/test").Route, body: unknown) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the providers page for an account that has not been bootstrapped,
|
||||
* with the given clusters on the domains endpoint. Returns the page plus a
|
||||
* promise-friendly getter for the bootstrap POST body, which is captured and
|
||||
* answered locally so the real backend is never mutated.
|
||||
*/
|
||||
async function openWizard(
|
||||
browser: Browser,
|
||||
domains: ReturnType<typeof freeDomain>[],
|
||||
): Promise<{ page: Page; close: () => Promise<void> }> {
|
||||
const context = await browser.newContext({
|
||||
storageState: "e2e/fixtures/auth/owner.json",
|
||||
});
|
||||
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 page.route("**/api/agent-network/settings", (route) => {
|
||||
const method = route.request().method();
|
||||
if (method === "GET") return json(route, UNBOOTSTRAPPED_DEFAULTS);
|
||||
if (method === "POST") return json(route, BOOTSTRAPPED_SETTINGS);
|
||||
return route.continue();
|
||||
});
|
||||
await page.route("**/api/reverse-proxies/domains", (route) =>
|
||||
route.request().method() === "GET"
|
||||
? json(route, domains)
|
||||
: route.continue(),
|
||||
);
|
||||
// Keep the create hermetic: the wizard only reaches this once the
|
||||
// bootstrap it does first has succeeded.
|
||||
await page.route("**/api/agent-network/providers", (route) =>
|
||||
route.request().method() === "POST"
|
||||
? json(route, { id: "prov-e2e", name: "e2e-provider" })
|
||||
: route.continue(),
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fills the provider tab with the minimum a create needs. */
|
||||
async function fillProviderTab(page: Page) {
|
||||
await page.getByPlaceholder("https://api.openai.com").fill("https://api.openai.com");
|
||||
await page.getByPlaceholder("sk-...").first().fill("sk-e2e-bootstrap-key");
|
||||
}
|
||||
|
||||
test.describe("Agent Network bootstrap cluster @agent-network", () => {
|
||||
test("bootstraps onto a private-capable cluster, skipping a centralised one listed first", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openWizard(browser, [
|
||||
freeDomain(CENTRAL_CLUSTER, false),
|
||||
freeDomain(EMBEDDED_CLUSTER, true),
|
||||
]);
|
||||
try {
|
||||
await page
|
||||
.getByRole("button", { name: "Connect Provider" })
|
||||
.first()
|
||||
.click({ force: true });
|
||||
|
||||
// The unusable cluster is listed, but it is not a blocker: a usable one
|
||||
// exists, so the wizard proceeds without the warning.
|
||||
await expect(
|
||||
page.getByTestId("agent-network-no-cluster-callout"),
|
||||
).toHaveCount(0);
|
||||
|
||||
await fillProviderTab(page);
|
||||
|
||||
const bootstrap = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/agent-network/settings") &&
|
||||
resp.request().method() === "POST",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: "Continue" }).click({ force: true });
|
||||
await page
|
||||
.getByRole("button", { name: "Connect Provider" })
|
||||
.last()
|
||||
.click({ force: true });
|
||||
|
||||
const payload = (await bootstrap).request().postDataJSON();
|
||||
expect(payload.proxy_address).toBe(EMBEDDED_CLUSTER);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("blocks setup when no listed cluster has an embedded proxy", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openWizard(browser, [
|
||||
freeDomain(CENTRAL_CLUSTER, false),
|
||||
]);
|
||||
try {
|
||||
await page
|
||||
.getByRole("button", { name: "Connect Provider" })
|
||||
.first()
|
||||
.click({ force: true });
|
||||
|
||||
const callout = page.getByTestId("agent-network-no-cluster-callout");
|
||||
await expect(callout).toBeVisible({ timeout: 10_000 });
|
||||
await expect(callout).toContainText("embedded proxy");
|
||||
|
||||
// With nothing to pin to there is no bootstrap to make, so the wizard
|
||||
// cannot advance — rather than sending a request management refuses.
|
||||
await fillProviderTab(page);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Continue" }),
|
||||
).toBeDisabled();
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,9 @@
|
||||
*
|
||||
* 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 —
|
||||
@@ -19,20 +19,57 @@
|
||||
* 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,
|
||||
supportsAgentNetworkSettingsBootstrap,
|
||||
} 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>;
|
||||
@@ -71,8 +108,17 @@ test.describe.serial("Agent Network Kimi provider @agent-network", () => {
|
||||
"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");
|
||||
@@ -153,17 +199,36 @@ test.describe.serial("Agent Network Kimi provider @agent-network", () => {
|
||||
.click({ force: true });
|
||||
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
|
||||
@@ -192,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,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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "../helpers/fixtures";
|
||||
import { createGroup, deleteGroup, listGroups } from "../helpers/api";
|
||||
import { generateRandomName } from "../helpers/utils";
|
||||
import {
|
||||
canvasNode,
|
||||
clickContextMenuItem,
|
||||
connectNodes,
|
||||
dragNodeOnto,
|
||||
dragTemplateToCanvas,
|
||||
enterDraft,
|
||||
expectChangeCount,
|
||||
openControlCenter,
|
||||
readDraftChanges,
|
||||
resetDraftState,
|
||||
reviewButton,
|
||||
submitCreatePolicyModal,
|
||||
} from "../helpers/control-center";
|
||||
|
||||
test.describe.serial("Control Center Draft Connections @control-center", () => {
|
||||
test.beforeEach(async ({ dashboardAsOwner: page }) => {
|
||||
await resetDraftState(page);
|
||||
});
|
||||
|
||||
test("Should create a policy by connecting two groups", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
// Two groups, placed apart so the connect drag has a clear path.
|
||||
const pane = await page.locator(".react-flow__pane").boundingBox();
|
||||
if (!pane) throw new Error("no pane");
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.45,
|
||||
y: pane.y + pane.height * 0.35,
|
||||
});
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.75,
|
||||
y: pane.y + pane.height * 0.65,
|
||||
});
|
||||
const groups = canvasNode(page, "group-new-");
|
||||
await expect(groups).toHaveCount(2);
|
||||
await expectChangeCount(page, 2);
|
||||
|
||||
await connectNodes(page, groups.nth(0), groups.nth(1));
|
||||
await expect(page.getByTestId("create-policy-title")).toBeVisible();
|
||||
await submitCreatePolicyModal(page);
|
||||
|
||||
await expect(canvasNode(page, "policy-new-")).toHaveCount(1);
|
||||
await expect(page.locator(".react-flow__edge")).toHaveCount(2);
|
||||
await expectChangeCount(page, 3);
|
||||
const changes = await readDraftChanges(page);
|
||||
expect(changes.filter((c) => c.type === "create-policy")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("Should track a placeholder-peer policy but block its deploy on the peer install", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
const pane = await page.locator(".react-flow__pane").boundingBox();
|
||||
if (!pane) throw new Error("no pane");
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.45,
|
||||
y: pane.y + pane.height * 0.35,
|
||||
});
|
||||
await dragTemplateToCanvas(page, "cc-template-peer-server", {
|
||||
x: pane.x + pane.width * 0.75,
|
||||
y: pane.y + pane.height * 0.65,
|
||||
});
|
||||
const group = canvasNode(page, "group-new-").first();
|
||||
const peer = page.locator('.react-flow__node[data-id^="peer-"]');
|
||||
await expect(peer).toHaveCount(1);
|
||||
|
||||
await connectNodes(page, peer, group);
|
||||
await expect(page.getByTestId("create-policy-title")).toBeVisible();
|
||||
await submitCreatePolicyModal(page);
|
||||
|
||||
await expect(canvasNode(page, "policy-new-")).toHaveCount(1);
|
||||
// The placeholder's install-peer change gates the deploy, not the policy.
|
||||
const changes = await readDraftChanges(page);
|
||||
expect(changes.filter((c) => c.type === "create-policy")).toHaveLength(1);
|
||||
expect(changes.filter((c) => c.type === "install-peer")).toHaveLength(1);
|
||||
await expectChangeCount(page, 3);
|
||||
});
|
||||
|
||||
test("Should rename a group and propagate the name into the policy changeset", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
const pane = await page.locator(".react-flow__pane").boundingBox();
|
||||
if (!pane) throw new Error("no pane");
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.45,
|
||||
y: pane.y + pane.height * 0.35,
|
||||
});
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.75,
|
||||
y: pane.y + pane.height * 0.65,
|
||||
});
|
||||
const groups = canvasNode(page, "group-new-");
|
||||
await connectNodes(page, groups.nth(0), groups.nth(1));
|
||||
await submitCreatePolicyModal(page);
|
||||
await expectChangeCount(page, 3);
|
||||
|
||||
const sourceGroup = groups.nth(0);
|
||||
const newName = generateRandomName("cc-renamed-");
|
||||
await clickContextMenuItem(page, sourceGroup, "rename");
|
||||
await page.getByTestId("cc-rename-input").fill(newName);
|
||||
await page.getByTestId("cc-rename-submit").click();
|
||||
|
||||
await expect(sourceGroup).toContainText(newName);
|
||||
// The rename folds into the create-group entry instead of adding one.
|
||||
await expectChangeCount(page, 3);
|
||||
const changes = await readDraftChanges(page);
|
||||
expect(
|
||||
changes.some((c) => c.type === "create-group" && c.name === newName),
|
||||
).toBe(true);
|
||||
const policy = changes.find((c) => c.type === "create-policy");
|
||||
const rule = policy.policy.rules[0];
|
||||
const referenced = [
|
||||
...(rule.sources ?? []),
|
||||
...(rule.destinations ?? []),
|
||||
].map((g) => (typeof g === "string" ? g : g.name));
|
||||
expect(referenced).toContain(newName);
|
||||
|
||||
// The editor seeds from the canvas policy node, so it must show the rename.
|
||||
const policyNode = canvasNode(page, "policy-new-");
|
||||
await clickContextMenuItem(page, policyNode, "edit");
|
||||
await expect(page.getByTestId("source-group-selector")).toContainText(
|
||||
newName,
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
test("Should create a group inline in the policy modal and show it on canvas", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
const pane = await page.locator(".react-flow__pane").boundingBox();
|
||||
if (!pane) throw new Error("no pane");
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.45,
|
||||
y: pane.y + pane.height * 0.35,
|
||||
});
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.75,
|
||||
y: pane.y + pane.height * 0.65,
|
||||
});
|
||||
const groups = canvasNode(page, "group-new-");
|
||||
await connectNodes(page, groups.nth(0), groups.nth(1));
|
||||
await expect(page.getByTestId("create-policy-title")).toBeVisible();
|
||||
|
||||
const inlineName = generateRandomName("cc-inline-");
|
||||
const selector = page.getByTestId("destination-group-selector");
|
||||
const search = page.getByTestId("destination-group-selector-search");
|
||||
// The popover open can be swallowed while the modal is still animating in.
|
||||
await expect(async () => {
|
||||
await selector.click();
|
||||
await expect(search).toBeVisible({ timeout: 1000 });
|
||||
}).toPass();
|
||||
await search.fill(inlineName);
|
||||
await page.keyboard.press("Enter");
|
||||
// Escape would close the whole modal once the popover is already gone.
|
||||
await page.getByTestId("create-policy-title").click();
|
||||
await expect(search).toBeHidden();
|
||||
await submitCreatePolicyModal(page);
|
||||
|
||||
await expect(
|
||||
page.locator(".react-flow__node", { hasText: inlineName }),
|
||||
).toHaveCount(1);
|
||||
const changes = await readDraftChanges(page);
|
||||
expect(
|
||||
changes.some((c) => c.type === "create-group" && c.name === inlineName),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("Should edit a draft policy and reflect the change on the canvas", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
const pane = await page.locator(".react-flow__pane").boundingBox();
|
||||
if (!pane) throw new Error("no pane");
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.45,
|
||||
y: pane.y + pane.height * 0.35,
|
||||
});
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.75,
|
||||
y: pane.y + pane.height * 0.65,
|
||||
});
|
||||
const groups = canvasNode(page, "group-new-");
|
||||
await connectNodes(page, groups.nth(0), groups.nth(1));
|
||||
await submitCreatePolicyModal(page);
|
||||
const policyNode = canvasNode(page, "policy-new-");
|
||||
await expect(policyNode).toHaveCount(1);
|
||||
|
||||
const newName = generateRandomName("cc-policy-");
|
||||
await clickContextMenuItem(page, policyNode, "edit");
|
||||
await expect(page.getByTestId("update-policy-title")).toBeVisible();
|
||||
await page.getByRole("tab", { name: "Name & Description" }).click();
|
||||
await page.getByTestId("policy-name").fill(newName);
|
||||
await page.getByTestId("submit-policy").click();
|
||||
|
||||
await expect(policyNode).toContainText(newName);
|
||||
const changes = await readDraftChanges(page);
|
||||
const policy = changes.find((c) => c.type === "create-policy");
|
||||
expect(policy.policy.name).toBe(newName);
|
||||
});
|
||||
|
||||
test("Should remove a draft policy from the canvas without a confirmation", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
const pane = await page.locator(".react-flow__pane").boundingBox();
|
||||
if (!pane) throw new Error("no pane");
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.45,
|
||||
y: pane.y + pane.height * 0.35,
|
||||
});
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.75,
|
||||
y: pane.y + pane.height * 0.65,
|
||||
});
|
||||
const groups = canvasNode(page, "group-new-");
|
||||
await connectNodes(page, groups.nth(0), groups.nth(1));
|
||||
await submitCreatePolicyModal(page);
|
||||
await expectChangeCount(page, 3);
|
||||
|
||||
const policyNode = canvasNode(page, "policy-new-");
|
||||
await clickContextMenuItem(page, policyNode, "remove");
|
||||
await expect(page.getByTestId("confirmation.confirm")).not.toBeVisible();
|
||||
await expect(policyNode).toHaveCount(0);
|
||||
await expect(page.locator(".react-flow__edge")).toHaveCount(0);
|
||||
await expect(groups).toHaveCount(2);
|
||||
await expectChangeCount(page, 2);
|
||||
});
|
||||
|
||||
test("Should drop a peer into a group and track the membership", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
const pane = await page.locator(".react-flow__pane").boundingBox();
|
||||
if (!pane) throw new Error("no pane");
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.45,
|
||||
y: pane.y + pane.height * 0.35,
|
||||
});
|
||||
await dragTemplateToCanvas(page, "cc-template-peer-server", {
|
||||
x: pane.x + pane.width * 0.75,
|
||||
y: pane.y + pane.height * 0.65,
|
||||
});
|
||||
const group = canvasNode(page, "group-new-").first();
|
||||
const peer = page.locator('.react-flow__node[data-id^="peer-"]');
|
||||
await expect(peer).toHaveCount(1);
|
||||
|
||||
await dragNodeOnto(page, peer, group);
|
||||
await expect(peer).toHaveCount(0);
|
||||
await expect(group).toContainText(/1\s*peer/i);
|
||||
|
||||
const changes = await readDraftChanges(page);
|
||||
const createGroupChange = changes.find((c) => c.type === "create-group");
|
||||
expect(createGroupChange.peerIds).toHaveLength(1);
|
||||
await expectChangeCount(page, 2);
|
||||
|
||||
// The placeholder isn't in the API peer list but must still be listed.
|
||||
await clickContextMenuItem(page, group, "view-details");
|
||||
await expect(
|
||||
page.getByText("Server", { exact: false }).last(),
|
||||
).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
test("Should delete an existing group in draft and deploy the deletion", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
// The API helper navigates away to capture the token, so re-open the canvas.
|
||||
const name = generateRandomName("cc-delete-");
|
||||
const seeded = await createGroup(page, name);
|
||||
await openControlCenter(page);
|
||||
await enterDraft(page);
|
||||
|
||||
// Search narrows the virtualized list so the row actually renders.
|
||||
await dragTemplateToCanvas(page, `cc-panel-group-${seeded.id}`, undefined, {
|
||||
search: name,
|
||||
});
|
||||
|
||||
const groupNode = page.locator(
|
||||
`.react-flow__node[data-id="group-${seeded.id}"]`,
|
||||
);
|
||||
await expect(groupNode).toHaveCount(1);
|
||||
|
||||
await clickContextMenuItem(page, groupNode, "delete");
|
||||
await expect(page.getByTestId("confirmation.title")).toContainText(name);
|
||||
await page.getByTestId("confirmation.confirm").click();
|
||||
await expect(groupNode).toHaveCount(0);
|
||||
await expectChangeCount(page, 1);
|
||||
|
||||
await reviewButton(page).click();
|
||||
const deleteResponse = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes(`/api/groups/${seeded.id}`) &&
|
||||
resp.request().method() === "DELETE",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.getByTestId("cc-deploy").click({ force: true });
|
||||
const response = await deleteResponse;
|
||||
expect(response.status()).toBeLessThan(300);
|
||||
|
||||
const remaining = await listGroups(page);
|
||||
expect(remaining.some((g) => g.id === seeded.id)).toBe(false);
|
||||
});
|
||||
|
||||
// Reuses the worker page: a fresh context's login can outrun the hook timeout.
|
||||
test.afterAll(async ({ dashboardAsOwner: page }) => {
|
||||
const groups = await listGroups(page).catch(() => []);
|
||||
for (const g of groups) {
|
||||
if (g.name.startsWith("cc-delete-") || g.name.startsWith("cc-inline-")) {
|
||||
await deleteGroup(page, g.id).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "../helpers/fixtures";
|
||||
import {
|
||||
canvasNode,
|
||||
createViaCanvasMenu,
|
||||
enterDraft,
|
||||
resetDraftState,
|
||||
} from "../helpers/control-center";
|
||||
|
||||
test.describe.serial("Control Center Draft Extras @control-center", () => {
|
||||
test.beforeEach(async ({ dashboardAsOwner: page }) => {
|
||||
await resetDraftState(page);
|
||||
});
|
||||
|
||||
test("New Network (canvas menu) adds a draft frame with its frame actions", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
await createViaCanvasMenu(page, "new-network");
|
||||
|
||||
const frame = canvasNode(page, "network-new-");
|
||||
await expect(frame).toHaveCount(1);
|
||||
|
||||
await frame.click({ button: "right" });
|
||||
const menu = page.getByTestId("cc-node-context-menu");
|
||||
await expect(menu).toBeVisible();
|
||||
for (const action of [
|
||||
"edit",
|
||||
"add-resource",
|
||||
"add-resource-group",
|
||||
"add-routing-peer",
|
||||
"remove",
|
||||
]) {
|
||||
await expect(menu.getByTestId(`cc-menu-${action}`)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("Auto Arrange repositions overlapping nodes", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
|
||||
// Two groups created at the viewport centre land on top of each other.
|
||||
await page.locator(".react-flow__pane").click();
|
||||
await page.keyboard.press("Alt+4");
|
||||
await page.keyboard.press("Alt+4");
|
||||
const groups = canvasNode(page, "group-new-");
|
||||
await expect(groups).toHaveCount(2);
|
||||
|
||||
await page.getByTestId("cc-toolbar-arrange").click();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const a = await groups.nth(0).boundingBox();
|
||||
const b = await groups.nth(1).boundingBox();
|
||||
if (!a || !b) return false;
|
||||
return Math.abs(a.x - b.x) > 5 || Math.abs(a.y - b.y) > 5;
|
||||
})
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test("Editing a draft network frame renames it on the canvas", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
await createViaCanvasMenu(page, "new-network");
|
||||
const frame = canvasNode(page, "network-new-");
|
||||
await expect(frame).toHaveCount(1);
|
||||
|
||||
await frame.click({ button: "right" });
|
||||
const menu = page.getByTestId("cc-node-context-menu");
|
||||
await expect(menu).toBeVisible();
|
||||
await menu.getByTestId("cc-menu-edit").click();
|
||||
|
||||
const newName = "Renamed Draft Net";
|
||||
await page.getByTestId("network-name-input").fill(newName);
|
||||
await page.getByTestId("submit-network").click({ force: true });
|
||||
|
||||
await expect(frame).toContainText(newName);
|
||||
});
|
||||
|
||||
test("A draft resource can be disabled and re-enabled via its context menu", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
|
||||
await createViaCanvasMenu(page, "new-resource");
|
||||
await page.getByTestId("resource-name-input").fill("toggle-res");
|
||||
await page.getByTestId("resource-address-input").fill("10.5.6.7/32");
|
||||
await page.getByTestId("submit-resource").click({ force: true });
|
||||
|
||||
const resource = canvasNode(page, "resource-new-");
|
||||
await expect(resource).toHaveCount(1);
|
||||
|
||||
const openMenu = async () => {
|
||||
await resource.click({ button: "right" });
|
||||
const menu = page.getByTestId("cc-node-context-menu");
|
||||
await expect(menu).toBeVisible();
|
||||
return menu;
|
||||
};
|
||||
|
||||
let menu = await openMenu();
|
||||
await menu.getByTestId("cc-menu-disable").click();
|
||||
|
||||
menu = await openMenu();
|
||||
await expect(menu.getByTestId("cc-menu-enable")).toBeVisible();
|
||||
await menu.getByTestId("cc-menu-enable").click();
|
||||
|
||||
menu = await openMenu();
|
||||
await expect(menu.getByTestId("cc-menu-disable")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
test("Add Routing Peer on a draft network frame opens the routing-peer modal", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
await createViaCanvasMenu(page, "new-network");
|
||||
const frame = canvasNode(page, "network-new-");
|
||||
await expect(frame).toHaveCount(1);
|
||||
|
||||
await frame.click({ button: "right" });
|
||||
const menu = page.getByTestId("cc-node-context-menu");
|
||||
await expect(menu).toBeVisible();
|
||||
await menu.getByTestId("cc-menu-add-routing-peer").click();
|
||||
|
||||
await expect(page.getByTestId("routing-peer-tab-peer")).toBeVisible();
|
||||
await expect(page.getByTestId("routing-peer-tab-group")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
test("A draft resource can be renamed via its context menu", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
await createViaCanvasMenu(page, "new-resource");
|
||||
await page.getByTestId("resource-name-input").fill("orig-res");
|
||||
await page.getByTestId("resource-address-input").fill("10.9.9.9/32");
|
||||
await page.getByTestId("submit-resource").click({ force: true });
|
||||
|
||||
const resource = canvasNode(page, "resource-new-");
|
||||
await expect(resource).toHaveCount(1);
|
||||
|
||||
await resource.click({ button: "right" });
|
||||
const menu = page.getByTestId("cc-node-context-menu");
|
||||
await expect(menu).toBeVisible();
|
||||
await menu.getByTestId("cc-menu-rename").click();
|
||||
|
||||
await page.getByTestId("cc-rename-input").fill("renamed-res");
|
||||
await page.getByTestId("cc-rename-submit").click({ force: true });
|
||||
|
||||
await expect(resource).toContainText("renamed-res");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "../helpers/fixtures";
|
||||
import {
|
||||
deleteGroupsByPrefix,
|
||||
deletePeersByPrefix,
|
||||
deleteSetupKeysByPrefix,
|
||||
listPeers,
|
||||
} from "../helpers/api";
|
||||
import { generateRandomName } from "../helpers/utils";
|
||||
import {
|
||||
canvasNode,
|
||||
createViaCanvasMenu,
|
||||
enterDraft,
|
||||
openControlCenter,
|
||||
resetDraftState,
|
||||
reviewButton,
|
||||
} from "../helpers/control-center";
|
||||
import {
|
||||
removeDockerContainer,
|
||||
runDockerPeerWithKey,
|
||||
} from "../helpers/docker-peer";
|
||||
|
||||
test.describe.serial("Control Center Draft Install @control-center", () => {
|
||||
test.beforeEach(async ({ dashboardAsOwner: page }) => {
|
||||
await resetDraftState(page);
|
||||
});
|
||||
|
||||
test("A Server placeholder's Install button opens the setup modal", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
await createViaCanvasMenu(page, "new-server");
|
||||
|
||||
const placeholder = canvasNode(page, "peer-");
|
||||
await expect(placeholder).toHaveCount(1);
|
||||
|
||||
await placeholder.hover();
|
||||
await page.getByTestId("cc-peer-install").click({ force: true });
|
||||
|
||||
await expect(page.getByTestId("setup-netbird-modal")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("setup-netbird-modal")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("A Server install command omits --hostname (matched by its bound group)", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
await createViaCanvasMenu(page, "new-server");
|
||||
const placeholder = canvasNode(page, "peer-");
|
||||
await expect(placeholder).toHaveCount(1);
|
||||
|
||||
await placeholder.hover();
|
||||
await page.getByTestId("cc-peer-install").click({ force: true });
|
||||
|
||||
const modal = page.getByTestId("setup-netbird-modal");
|
||||
await expect(modal).toBeVisible();
|
||||
// Placeholders match their machine by the hidden bound group, not a --hostname.
|
||||
await expect(modal).not.toContainText("--hostname");
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(modal).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("Review & Deploy opens the first change even when it's a Server install step", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
// A lone Server placeholder's install-peer change sorts to the top of the list.
|
||||
await createViaCanvasMenu(page, "new-server");
|
||||
await expect(canvasNode(page, "peer-")).toHaveCount(1);
|
||||
|
||||
await reviewButton(page).click();
|
||||
const item = page.getByTestId("cc-change-install-peer");
|
||||
await expect(item).toBeVisible();
|
||||
await expect(item).toHaveAttribute("data-state", "open");
|
||||
});
|
||||
|
||||
test("Full flow: a docker peer installs (upgrades) a Server placeholder", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const host = "cc-install-" + generateRandomName();
|
||||
try {
|
||||
// getApiContext's first call navigates to /team/users, which would drop the draft.
|
||||
await listPeers(page);
|
||||
await openControlCenter(page);
|
||||
|
||||
await enterDraft(page);
|
||||
await createViaCanvasMenu(page, "new-server");
|
||||
const placeholder = canvasNode(page, "peer-");
|
||||
await expect(placeholder).toHaveCount(1);
|
||||
|
||||
// The plaintext key is only in the POST response, so capture it there.
|
||||
await placeholder.hover();
|
||||
await page.getByTestId("cc-peer-install").click({ force: true });
|
||||
await expect(page.getByTestId("setup-netbird-modal")).toBeVisible();
|
||||
|
||||
const keyResponse = page.waitForResponse(
|
||||
(r) =>
|
||||
r.url().includes("/api/setup-keys") &&
|
||||
r.request().method() === "POST",
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
await page.getByTestId("setup-generate-key").click();
|
||||
const created = await (await keyResponse).json();
|
||||
const key: string = created.key;
|
||||
expect(key?.length ?? 0).toBeGreaterThan(0);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// The peer registers into the key's bound group.
|
||||
await runDockerPeerWithKey(page, host, key);
|
||||
|
||||
// Revalidate via SWR's revalidateOnFocus; focus is throttled, so dispatch per poll.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
return !(await page
|
||||
.getByTestId("cc-peer-install")
|
||||
.isVisible()
|
||||
.catch(() => false));
|
||||
},
|
||||
{ timeout: 90_000, intervals: [3000, 3000, 5000, 5000, 5000] },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
expect((await listPeers(page)).some((p) => p.name === host)).toBe(true);
|
||||
} finally {
|
||||
removeDockerContainer(host);
|
||||
await deletePeersByPrefix(page, host);
|
||||
await deleteSetupKeysByPrefix(page, "Draft Server");
|
||||
await deleteGroupsByPrefix(page, "Server (Draft)");
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 20 of 287 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user