diff --git a/.github/workflows/client-e2e.yml b/.github/workflows/client-e2e.yml
new file mode 100644
index 00000000..5aa1d397
--- /dev/null
+++ b/.github/workflows/client-e2e.yml
@@ -0,0 +1,91 @@
+# ===============================================================
+# React Client E2E - Playwright (TypeScript) smoke suite
+# ===============================================================
+# - runs TypeScript Playwright tests for the React admin UI
+# - Vite dev server is spawned by Playwright's webServer config
+# - backend API is stubbed with page.route() (no gateway required)
+# - triggered on PRs and pushes to main
+# ---------------------------------------------------------------
+
+name: Client E2E (Mocked API)
+
+on:
+ push:
+ branches: ["main"]
+ pull_request:
+ types: [opened, synchronize, reopened, ready_for_review]
+ branches: ["main"]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ playwright:
+ if: github.event_name != 'pull_request' || !github.event.pull_request.draft
+ name: Playwright mocked API tests
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+
+ env:
+ CI: "true"
+ PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
+
+ steps:
+ - name: Checkout source
+ uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
+ with:
+ persist-credentials: false
+ fetch-depth: 1
+
+ - name: Setup Node
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version-file: ".nvmrc"
+
+ - name: Install dependencies
+ run: npm ci --no-audit --no-fund
+
+ - name: Generate API code
+ run: npm run api:generate
+
+ - name: Cache Playwright browsers
+ id: playwright-cache
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
+ with:
+ path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }}
+ key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }}
+ restore-keys: |
+ ${{ runner.os }}-playwright-
+
+ - name: Install Playwright browsers
+ if: steps.playwright-cache.outputs.cache-hit != 'true'
+ run: npx playwright install --with-deps chromium
+
+ - name: Install Playwright system deps (cached browsers)
+ if: steps.playwright-cache.outputs.cache-hit == 'true'
+ run: npx playwright install-deps chromium
+
+ - name: Run Playwright tests
+ run: npm run playwright
+
+ - name: Upload Playwright HTML report
+ if: always()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ name: playwright-report
+ path: playwright-report
+ retention-days: 7
+ if-no-files-found: ignore
+
+ - name: Upload Playwright traces on failure
+ if: failure()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ name: playwright-traces
+ path: test-results
+ retention-days: 7
+ if-no-files-found: ignore
diff --git a/README.md b/README.md
index 1c48bd4d..ad7abec1 100644
--- a/README.md
+++ b/README.md
@@ -337,11 +337,11 @@ Playwright. They run against the Vite dev server and stub backend API calls
with `page.route()`, so no running ContextForge API is required.
```bash
-npm run e2e:install # Install Playwright browsers (one-time)
-npm run e2e # Headless run
-npm run e2e:ui # Interactive UI mode
-npm run e2e:debug # Playwright Inspector
-npm run e2e:report # Open the last HTML report
+npm run playwright:install # Install Playwright browsers (one-time)
+npm run playwright # Headless run
+npm run playwright:ui # Interactive UI mode
+npm run playwright:debug # Playwright Inspector
+npm run playwright:report # Open the last HTML report
```
See [`e2e/README.md`](./e2e/README.md) for layout, fixtures, and guidelines.
@@ -440,11 +440,11 @@ dev setup.
| `npm run test` | Run tests once (CI mode) |
| `npm run test:ui` | Run tests with UI |
| `npm run test:coverage` | Generate coverage report |
-| `npm run e2e` | Run Playwright E2E tests |
-| `npm run e2e:ui` | Playwright UI mode |
-| `npm run e2e:debug` | Playwright Inspector |
-| `npm run e2e:install` | Install Playwright browsers |
-| `npm run e2e:report` | Open last Playwright report |
+| `npm run playwright` | Run Playwright tests (mocked API) |
+| `npm run playwright:ui` | Playwright UI mode |
+| `npm run playwright:debug` | Playwright Inspector |
+| `npm run playwright:install` | Install Playwright browsers |
+| `npm run playwright:report` | Open last Playwright report |
| `npm run secrets:scan` | Scan repo for secrets, update `.secrets.baseline` |
| `npm run secrets:audit` | Interactively audit findings in `.secrets.baseline` |
diff --git a/e2e/README.md b/e2e/README.md
index 24bc3da7..09e2d791 100644
--- a/e2e/README.md
+++ b/e2e/README.md
@@ -30,24 +30,25 @@ e2e/
Install the Playwright browsers once per machine:
```bash
-npm run e2e:install
+npm run playwright:install
```
Then, from `client/`:
```bash
-npm run e2e # Headless run against the Vite dev server
-npm run e2e:ui # Interactive UI mode (great for authoring)
-npm run e2e:debug # Step through with the Playwright Inspector
-npm run e2e:report # Open the last HTML report
+npm run playwright # Headless run against a production build of the app
+npm run playwright:ui # Interactive UI mode (great for authoring)
+npm run playwright:debug # Step through with the Playwright Inspector
+npm run playwright:report # Open the last HTML report
```
-The config spawns `npm run dev:e2e` (Vite with `--base=/`) on port `5173` and
-tears it down after the run. Set `PLAYWRIGHT_BASE_URL` to point tests at an
-existing server instead:
+The config spawns `npm run playwright:serve` (a production build served by
+`vite preview`, with source maps) on port `5173` and tears it down after the
+run. App changes need a fresh run to rebuild; the build takes a few seconds.
+Set `PLAYWRIGHT_BASE_URL` to point tests at an existing server instead:
```bash
-PLAYWRIGHT_BASE_URL=http://localhost:4444 PLAYWRIGHT_SKIP_WEBSERVER=1 npm run e2e
+PLAYWRIGHT_BASE_URL=http://localhost:4444 PLAYWRIGHT_SKIP_WEBSERVER=1 npm run playwright
```
### Real-backend mode (`e2e:docker`)
@@ -134,7 +135,7 @@ API responses; real API Docker runs are available through the scripts above.
## Troubleshooting
- **`Error: Timed out waiting for ... to be visible`** — check the locator in UI
- mode (`npm run e2e:ui`) and confirm the mock returns what the UI expects.
+ mode (`npm run playwright:ui`) and confirm the mock returns what the UI expects.
- **Tests that pass locally but flake in CI** — add a `page.waitForLoadState`,
tighten the mock's payload, or widen the retry count in the config for the
specific test. Do not add arbitrary `waitForTimeout` calls.
diff --git a/e2e/global-search.spec.ts b/e2e/global-search.spec.ts
index 4d7a7b36..24acc1b8 100644
--- a/e2e/global-search.spec.ts
+++ b/e2e/global-search.spec.ts
@@ -92,7 +92,6 @@ test.describe("Global search", () => {
});
await page.goto(APP.SERVERS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Search" }).click();
const searchBox = page.getByRole("searchbox", { name: "Search" });
diff --git a/e2e/invitations.spec.ts b/e2e/invitations.spec.ts
index c95857e5..3aa1f4c4 100644
--- a/e2e/invitations.spec.ts
+++ b/e2e/invitations.spec.ts
@@ -116,6 +116,7 @@ test.describe("Pending team invitations", () => {
await mockInvitations(page, []);
await page.goto(APP.TEAMS);
+ // networkidle: the absence check below is only meaningful once the page settles.
await page.waitForLoadState("networkidle");
await expect(page.getByRole("button", { name: /invitation/ })).toHaveCount(0);
@@ -126,7 +127,6 @@ test.describe("Pending team invitations", () => {
await mockInvitations(page, [PLATFORM_INVITATION]);
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "1 team invitation" }).click();
@@ -146,7 +146,6 @@ test.describe("Pending team invitations", () => {
await mockInvitations(page, [PLATFORM_INVITATION, DESIGN_INVITATION]);
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "2 team invitations" }).click();
@@ -161,7 +160,6 @@ test.describe("Pending team invitations", () => {
await mockInvitations(page, [PLATFORM_INVITATION, DESIGN_INVITATION]);
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "2 team invitations" }).click();
@@ -187,7 +185,6 @@ test.describe("Pending team invitations", () => {
await mockInvitations(page, [PLATFORM_INVITATION, DESIGN_INVITATION]);
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "2 team invitations" }).click();
@@ -211,7 +208,6 @@ test.describe("Pending team invitations", () => {
});
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "2 team invitations" }).click();
@@ -243,6 +239,7 @@ test.describe("Pending team invitations", () => {
await mockInvitations(page, [], { status: 500 });
await page.goto(APP.TEAMS);
+ // networkidle: the absence check below is only meaningful once the page settles.
await page.waitForLoadState("networkidle");
// No count is known, so no chip renders and the failure is not toasted.
@@ -255,7 +252,6 @@ test.describe("Pending team invitations", () => {
await mockInvitations(page, [PLATFORM_INVITATION]);
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "1 team invitation" }).click();
@@ -281,6 +277,7 @@ test.describe("Pending team invitations", () => {
await mockInvitations(page, [PLATFORM_INVITATION]);
await page.goto(APP.TEAMS);
+ // networkidle: the absence check below is only meaningful once the page settles.
await page.waitForLoadState("networkidle");
await expect(page).not.toHaveURL(/settings\/teams/);
diff --git a/e2e/oauth-authorization.spec.ts b/e2e/oauth-authorization.spec.ts
index f74078be..310f48fc 100644
--- a/e2e/oauth-authorization.spec.ts
+++ b/e2e/oauth-authorization.spec.ts
@@ -103,7 +103,6 @@ test.describe("OAuth authorization-code popup flow", () => {
});
await page.goto(APP.SERVERS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: /Connect/i }).click();
@@ -209,7 +208,6 @@ test.describe("OAuth authorization-code popup flow", () => {
});
await page.goto(APP.SERVERS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: /Connect/i }).click();
await page.getByLabel("Name").fill(GATEWAY_NAME);
@@ -261,7 +259,6 @@ test.describe("OAuth authorization-code popup flow", () => {
});
await page.goto(APP.SERVERS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: /Connect/i }).click();
await page.getByLabel("Name").fill(GATEWAY_NAME);
diff --git a/e2e/plugins.spec.ts b/e2e/plugins.spec.ts
index e8bda5ff..c3c2c087 100644
--- a/e2e/plugins.spec.ts
+++ b/e2e/plugins.spec.ts
@@ -60,7 +60,6 @@ test.describe("Plugins page", () => {
await mockPlugins(page, [GUARDRAILS, LOGGING]);
await page.goto(APP.PLUGINS);
- await page.waitForLoadState("networkidle");
await expect(page.getByRole("heading", { name: "Plugins" })).toBeVisible();
const list = page.getByRole("list", { name: "Catalog plugins" });
@@ -74,7 +73,6 @@ test.describe("Plugins page", () => {
await mockPlugins(page, [GUARDRAILS, LOGGING]);
await page.goto(APP.PLUGINS);
- await page.waitForLoadState("networkidle");
// ListSearch collapses to an icon; the button shares the input's aria-label
// and its click handler focuses (and thereby expands) the input.
@@ -90,7 +88,6 @@ test.describe("Plugins page", () => {
await mockPlugins(page, [GUARDRAILS, LOGGING]);
await page.goto(APP.PLUGINS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: /^Filters(, \d+ active)?$/ }).click();
await page.getByRole("combobox", { name: "Hook" }).click();
@@ -116,7 +113,6 @@ test.describe("Plugins page", () => {
await mockPlugins(page, [GUARDRAILS, LOGGING]);
await page.goto(APP.PLUGINS);
- await page.waitForLoadState("networkidle");
const viewToggle = page.getByRole("group", { name: "Plugins view" });
await viewToggle.getByRole("button", { name: "Enabled" }).click();
@@ -133,7 +129,6 @@ test.describe("Plugins page", () => {
await mockPlugins(page, [GUARDRAILS, LOGGING]);
await page.goto(APP.PLUGINS);
- await page.waitForLoadState("networkidle");
const viewButton = page.getByRole("button", { name: "View PII Guardrails" });
await viewButton.click();
@@ -157,7 +152,6 @@ test.describe("Plugins page", () => {
await mockPlugins(page, [], false);
await page.goto(APP.PLUGINS);
- await page.waitForLoadState("networkidle");
await expect(
page.getByText(
@@ -197,7 +191,6 @@ test.describe("Plugins page", () => {
});
await page.goto(APP.PLUGINS);
- await page.waitForLoadState("networkidle");
await expect(page.getByRole("alert")).toHaveText("Unable to load plugins. Try again.");
diff --git a/e2e/prompts.spec.ts b/e2e/prompts.spec.ts
index e77ebd36..b5f147f1 100644
--- a/e2e/prompts.spec.ts
+++ b/e2e/prompts.spec.ts
@@ -60,7 +60,6 @@ test.describe("Prompts page", () => {
});
await page.goto(APP.PROMPTS);
- await page.waitForLoadState("networkidle");
await expect(page.getByRole("heading", { name: "Prompts" })).toBeVisible();
await expect(page.getByRole("button", { name: "Add prompts" })).toBeVisible();
@@ -81,7 +80,6 @@ test.describe("Prompts page", () => {
});
await page.goto(APP.PROMPTS);
- await page.waitForLoadState("networkidle");
await expect(page.getByRole("heading", { name: "Prompts" })).toBeVisible();
await expect(page.getByRole("button", { name: "Add prompts" })).not.toBeVisible();
@@ -97,7 +95,6 @@ test.describe("Prompts page", () => {
});
await page.goto(APP.PROMPTS);
- await page.waitForLoadState("networkidle");
const alert = page.getByRole("alert");
await expect(alert).toBeVisible();
@@ -123,7 +120,6 @@ test.describe("Prompts page", () => {
});
await page.goto(APP.PROMPTS);
- await page.waitForLoadState("networkidle");
await expect(page.getByText("github-server")).toBeVisible();
await expect(page.getByText("REST prompts")).toBeVisible();
@@ -146,7 +142,6 @@ test.describe("Prompts page", () => {
});
await page.goto(APP.PROMPTS);
- await page.waitForLoadState("networkidle");
await expect(page.getByText("prompt_1")).toBeVisible();
await expect(page.getByText("prompt_8")).toBeVisible();
@@ -165,7 +160,6 @@ test.describe("Prompts page", () => {
});
await page.goto(APP.PROMPTS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "More options for github-server" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -193,7 +187,6 @@ test.describe("Prompts page", () => {
});
await page.goto(APP.PROMPTS);
- await page.waitForLoadState("networkidle");
const kebab = page.getByRole("button", { name: `More options for ${longSlug}` });
await expect(kebab).toBeVisible();
@@ -254,7 +247,6 @@ test.describe("Prompts page", () => {
});
await page.goto(APP.PROMPTS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Add prompts" }).click();
await expect(page.getByRole("heading", { name: "Add prompt" })).toBeVisible();
@@ -308,7 +300,6 @@ test.describe("Prompts page", () => {
});
await page.goto(APP.PROMPTS);
- await page.waitForLoadState("networkidle");
// Open the details panel for the REST prompts group.
await page.getByRole("button", { name: "More options for REST prompts" }).click();
@@ -374,7 +365,6 @@ test.describe("Prompts page", () => {
});
await page.goto(APP.PROMPTS);
- await page.waitForLoadState("networkidle");
// Open the federated group's panel and launch the edit form.
await page.getByRole("button", { name: "More options for hugging-face" }).click();
@@ -443,7 +433,6 @@ test.describe("Prompts page", () => {
});
await page.goto(APP.PROMPTS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "More options for REST prompts" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -497,7 +486,6 @@ test.describe("Prompts page", () => {
});
await page.goto(APP.PROMPTS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "More options for REST prompts" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -547,7 +535,6 @@ test.describe("Prompts page", () => {
});
await page.goto(APP.PROMPTS);
- await page.waitForLoadState("networkidle");
const { panel, dialog } = await openDeleteDialog(page);
await expect(dialog).toBeVisible();
@@ -582,7 +569,6 @@ test.describe("Prompts page", () => {
});
await page.goto(APP.PROMPTS);
- await page.waitForLoadState("networkidle");
const { panel, dialog } = await openDeleteDialog(page);
await dialog.getByRole("button", { name: "Delete" }).click();
@@ -627,7 +613,6 @@ test.describe("Prompts page", () => {
});
await page.goto(APP.PROMPTS);
- await page.waitForLoadState("networkidle");
const { panel, dialog } = await openDeleteDialog(page);
await dialog.getByRole("button", { name: "Delete" }).click();
diff --git a/e2e/quick-add-server.spec.ts b/e2e/quick-add-server.spec.ts
index ad6cc587..b42bb941 100644
--- a/e2e/quick-add-server.spec.ts
+++ b/e2e/quick-add-server.spec.ts
@@ -51,7 +51,6 @@ async function openQuickAddDialog(page: import("@playwright/test").Page) {
});
await page.goto(APP.SERVERS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: /Connect/i }).click();
await page.getByRole("button", { name: /mcp server catalog/i }).click();
diff --git a/e2e/resources.spec.ts b/e2e/resources.spec.ts
index d0f101f2..74ff8b05 100644
--- a/e2e/resources.spec.ts
+++ b/e2e/resources.spec.ts
@@ -49,7 +49,6 @@ test.describe("Resources page", () => {
});
await page.goto(APP.RESOURCES);
- await page.waitForLoadState("networkidle");
await expect(page.getByText("Add resources")).toBeVisible();
await expect(
@@ -71,7 +70,6 @@ test.describe("Resources page", () => {
});
await page.goto(APP.RESOURCES);
- await page.waitForLoadState("networkidle");
await expect(page.getByRole("heading", { name: "Resources" })).toBeVisible();
await expect(page.getByText("Add resources")).not.toBeVisible();
@@ -87,7 +85,6 @@ test.describe("Resources page", () => {
});
await page.goto(APP.RESOURCES);
- await page.waitForLoadState("networkidle");
await page.getByText("Add resources").click();
@@ -105,7 +102,6 @@ test.describe("Resources page", () => {
});
await page.goto(APP.RESOURCES);
- await page.waitForLoadState("networkidle");
await expect(page.getByRole("alert")).toBeVisible();
await expect(page.getByText("Error loading resources")).toBeVisible();
@@ -121,7 +117,6 @@ test.describe("Resources page", () => {
});
await page.goto(APP.RESOURCES);
- await page.waitForLoadState("networkidle");
await expect(page.getByRole("heading", { name: "Resources" })).toBeVisible();
@@ -150,7 +145,6 @@ test.describe("Resources page", () => {
});
await page.goto(APP.RESOURCES);
- await page.waitForLoadState("networkidle");
await expect(page.getByText("big-gateway")).toBeVisible();
await expect(page.getByText("10 resources")).toBeVisible();
@@ -173,7 +167,6 @@ test.describe("Resources page", () => {
});
await page.goto(APP.RESOURCES);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "More options for github-server" }).click();
@@ -199,7 +192,6 @@ test.describe("Resources page", () => {
});
await page.goto(APP.RESOURCES);
- await page.waitForLoadState("networkidle");
const kebab = page.getByRole("button", { name: `More options for ${longSlug}` });
await expect(kebab).toBeVisible();
@@ -231,7 +223,6 @@ test.describe("Resources page", () => {
});
await page.goto(APP.RESOURCES);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "More options for github-server" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -253,7 +244,6 @@ test.describe("Resources page", () => {
});
await page.goto(APP.RESOURCES);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "More options for github-server" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -289,7 +279,6 @@ test.describe("Resources page", () => {
});
await page.goto(APP.RESOURCES);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "More options for github-server" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -333,7 +322,6 @@ test.describe("Resources page", () => {
});
await page.goto(APP.RESOURCES);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "More options for github-server" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -369,7 +357,6 @@ test.describe("Resources page", () => {
});
await page.goto(APP.RESOURCES);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "More options for github-server" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -413,7 +400,6 @@ test.describe("Resources page", () => {
});
await page.goto(APP.RESOURCES);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "More options for github-server" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -462,7 +448,6 @@ test.describe("Resources page", () => {
});
await page.goto(APP.RESOURCES);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "More options for github-server" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -507,7 +492,6 @@ test.describe("Resources page", () => {
});
await page.goto(APP.RESOURCES);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "More options for solo-gateway" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -555,7 +539,6 @@ test.describe("Resources page", () => {
});
await page.goto(APP.RESOURCES);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "More options for multi-gw" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -603,7 +586,6 @@ test.describe("Resources page", () => {
});
await page.goto(APP.RESOURCES);
- await page.waitForLoadState("networkidle");
await expect(page.getByText("lone-gateway")).toBeVisible();
await expect(page.getByText("github-server")).toBeVisible();
@@ -662,7 +644,6 @@ test.describe("Resources page", () => {
});
await page.goto(APP.RESOURCES);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "More options for github-server" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -719,7 +700,6 @@ test.describe("Resources page", () => {
});
await page.goto(APP.RESOURCES);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "More options for github-server" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
diff --git a/e2e/server-catalog.spec.ts b/e2e/server-catalog.spec.ts
index f6ec9235..e565b2cf 100644
--- a/e2e/server-catalog.spec.ts
+++ b/e2e/server-catalog.spec.ts
@@ -146,7 +146,6 @@ test.describe("Server catalog page", () => {
await mockCatalog(page, [OPEN_CONNECTED, OPEN_AVAILABLE, API_KEY_SERVER]);
await page.goto(APP.SERVER_CATALOG);
- await page.waitForLoadState("networkidle");
await expect(page.getByRole("heading", { name: "Server catalog" })).toBeVisible();
const catalogList = page.getByRole("list", { name: "Catalog servers" });
@@ -162,7 +161,6 @@ test.describe("Server catalog page", () => {
await mockCatalog(page, [OPEN_CONNECTED, OPEN_AVAILABLE]);
await page.goto(APP.SERVER_CATALOG);
- await page.waitForLoadState("networkidle");
// ListSearch collapses to an icon; the button shares the input's aria-label
// and its click handler focuses (and thereby expands) the input.
@@ -178,7 +176,6 @@ test.describe("Server catalog page", () => {
await mockCatalog(page, [OPEN_CONNECTED, OPEN_AVAILABLE]);
await page.goto(APP.SERVER_CATALOG);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: /^Filters(, \d+ active)?$/ }).click();
const popover = page.getByRole("dialog", { name: "Filters" });
@@ -201,7 +198,6 @@ test.describe("Server catalog page", () => {
await mockCatalog(page, [OPEN_CONNECTED, OPEN_AVAILABLE]);
await page.goto(APP.SERVER_CATALOG);
- await page.waitForLoadState("networkidle");
const viewToggle = page.getByRole("group", { name: "Catalog view" });
await viewToggle.getByRole("button", { name: "Connected" }).click();
@@ -220,7 +216,6 @@ test.describe("Server catalog page", () => {
await mockCatalog(page, [OPEN_CONNECTED, OPEN_AVAILABLE]);
await page.goto(APP.SERVER_CATALOG);
- await page.waitForLoadState("networkidle");
const actionsButton = page.getByRole("button", { name: "Actions for Globalping" });
await actionsButton.click();
@@ -253,7 +248,6 @@ test.describe("Server catalog page", () => {
});
await page.goto(APP.SERVER_CATALOG);
- await page.waitForLoadState("networkidle");
await expect(page.getByText("Server catalog is disabled for this gateway.")).toBeVisible();
});
@@ -280,7 +274,6 @@ test.describe("Server catalog page", () => {
});
await page.goto(APP.SERVER_CATALOG);
- await page.waitForLoadState("networkidle");
await expect(page.getByRole("alert")).toHaveText("Unable to load server catalog. Try again.");
@@ -403,7 +396,6 @@ test.describe("Server catalog page", () => {
);
await page.goto(APP.SERVER_CATALOG);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: /^Filters(, \d+ active)?$/ }).click();
await page.getByRole("group", { name: "Providers" }).waitFor();
diff --git a/e2e/servers.spec.ts b/e2e/servers.spec.ts
index c960d222..7050a766 100644
--- a/e2e/servers.spec.ts
+++ b/e2e/servers.spec.ts
@@ -56,7 +56,6 @@ test.describe("MCP Servers page", () => {
});
await page.goto(APP.SERVERS);
- await page.waitForLoadState("networkidle");
await expect(page.getByText("Connect MCP server")).toBeVisible();
await expect(
@@ -76,6 +75,7 @@ test.describe("MCP Servers page", () => {
});
await page.goto(APP.SERVERS);
+ // networkidle: the absence check below is only meaningful once the page settles.
await page.waitForLoadState("networkidle");
await expect(page.getByText("Connect MCP server")).not.toBeVisible();
@@ -96,7 +96,6 @@ test.describe("MCP Servers page", () => {
});
await page.goto(APP.SERVERS);
- await page.waitForLoadState("networkidle");
await expect(page.getByRole("heading", { name: "MCP Servers" })).toBeVisible();
await expect(page.getByRole("button", { name: /Connect/i })).not.toBeVisible();
@@ -112,7 +111,6 @@ test.describe("MCP Servers page", () => {
});
await page.goto(APP.SERVERS);
- await page.waitForLoadState("networkidle");
await expect(page.getByRole("heading", { name: "MCP Servers" })).toBeVisible();
await expect(page.getByRole("row").filter({ hasText: "GitHub MCP Server" })).toBeVisible();
@@ -129,7 +127,6 @@ test.describe("MCP Servers page", () => {
});
await page.goto(APP.SERVERS);
- await page.waitForLoadState("networkidle");
await expect(page.getByRole("alert")).toBeVisible();
await expect(page.getByText("Error loading servers")).toBeVisible();
@@ -145,7 +142,6 @@ test.describe("MCP Servers page", () => {
});
await page.goto(APP.SERVERS);
- await page.waitForLoadState("networkidle");
await expect(page.getByRole("row").filter({ hasText: "GitHub MCP Server" })).toBeVisible();
await expect(page.getByRole("row").filter({ hasText: "Slack MCP Server" })).toBeVisible();
@@ -168,7 +164,6 @@ test.describe("MCP Servers page", () => {
});
await page.goto(APP.SERVERS);
- await page.waitForLoadState("networkidle");
await expect(page.getByRole("row").filter({ hasText: "GitHub MCP Server" })).toBeVisible();
await expect(page.getByRole("row").filter({ hasText: "Slack MCP Server" })).not.toBeVisible();
@@ -192,6 +187,7 @@ test.describe("MCP Servers page", () => {
});
await page.goto(APP.SERVERS);
+ // networkidle: the absence check below is only meaningful once the page settles.
await page.waitForLoadState("networkidle");
await expect(page.getByRole("button", { name: /Load More/i })).not.toBeVisible();
@@ -207,7 +203,6 @@ test.describe("MCP Servers page", () => {
});
await page.goto(APP.SERVERS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for GitHub MCP Server" }).click();
@@ -232,7 +227,6 @@ test.describe("MCP Servers page", () => {
});
await page.goto(APP.SERVERS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for GitHub MCP Server" }).click();
await expect(page.getByRole("menuitem", { name: "Refresh" })).toHaveCount(0);
@@ -267,7 +261,6 @@ test.describe("MCP Servers page", () => {
});
await page.goto(APP.SERVERS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for GitHub MCP Server" }).click();
await page.getByRole("menuitem", { name: "Refresh" }).click();
@@ -301,7 +294,6 @@ test.describe("MCP Servers page", () => {
});
await page.goto(APP.SERVERS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for GitHub MCP Server" }).click();
await page.getByRole("menuitem", { name: "Refresh" }).click();
@@ -328,7 +320,6 @@ test.describe("MCP Servers page", () => {
});
await page.goto(APP.SERVERS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for GitHub MCP Server" }).click();
await page.getByRole("menuitem", { name: "Refresh" }).click();
@@ -356,7 +347,6 @@ test.describe("MCP Servers page", () => {
});
await page.goto(APP.SERVERS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for GitHub MCP Server" }).click();
await page.getByRole("menuitem", { name: "Refresh" }).click();
@@ -387,7 +377,6 @@ test.describe("MCP Servers page", () => {
});
await page.goto(APP.SERVERS);
- await page.waitForLoadState("networkidle");
await expect(page.getByRole("row").filter({ hasText: "GitHub MCP Server" })).toBeVisible();
await expect(page.getByRole("row").filter({ hasText: "Slack MCP Server" })).toBeVisible();
@@ -424,7 +413,6 @@ test.describe("MCP Servers page", () => {
});
await page.goto(APP.SERVERS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for GitHub MCP Server" }).click();
await page.getByRole("menuitem", { name: "Delete" }).click();
@@ -459,7 +447,6 @@ test.describe("MCP Servers page", () => {
});
await page.goto(APP.SERVERS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for GitHub MCP Server" }).click();
await page.getByRole("menuitem", { name: "Delete" }).click();
@@ -520,7 +507,6 @@ test.describe("MCP Servers page", () => {
);
await page.goto(APP.SERVERS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for GitHub MCP Server" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -586,7 +572,6 @@ test.describe("MCP Servers page", () => {
);
await page.goto(APP.SERVERS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for GitHub MCP Server" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -653,7 +638,6 @@ test.describe("MCP Servers page", () => {
);
await page.goto(APP.SERVERS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for GitHub MCP Server" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -715,7 +699,6 @@ test.describe("MCP Servers page", () => {
});
await page.goto(APP.SERVERS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for GitHub MCP Server" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -735,7 +718,6 @@ test.describe("MCP Servers page", () => {
});
await page.goto(APP.SERVERS);
- await page.waitForLoadState("networkidle");
await expect(page.locator("#limit-select")).toBeVisible();
await expect(page.getByText(/Per page:/i)).toBeVisible();
diff --git a/e2e/smoke/team-switcher.spec.ts b/e2e/smoke/team-switcher.spec.ts
index d2cecb24..f1f65cf5 100644
--- a/e2e/smoke/team-switcher.spec.ts
+++ b/e2e/smoke/team-switcher.spec.ts
@@ -22,7 +22,6 @@ test.describe("TeamSwitcher component (smoke)", () => {
// Navigate to a page with sidebar
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
// Wait for TeamSwitcher to be visible
const trigger = page.getByRole("button", { name: /all teams/i });
@@ -53,7 +52,6 @@ test.describe("TeamSwitcher component (smoke)", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
// TeamSwitcher should still render with "All teams"
const trigger = page.getByRole("button", { name: /all teams/i });
diff --git a/e2e/teams.spec.ts b/e2e/teams.spec.ts
index 4a8a32fc..6e008b0e 100644
--- a/e2e/teams.spec.ts
+++ b/e2e/teams.spec.ts
@@ -121,7 +121,6 @@ test.describe("Teams page", () => {
});
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
const main = page.getByRole("main");
await expect(page.getByRole("heading", { name: "Teams" })).toBeVisible();
@@ -146,7 +145,6 @@ test.describe("Teams page", () => {
});
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await expect(page.getByText("No teams yet")).toBeVisible();
await expect(
@@ -173,7 +171,6 @@ test.describe("Teams page", () => {
});
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await expect(page.getByText("Engineering")).toBeVisible();
await expect(page.getByText("Public Team")).toBeVisible();
@@ -232,7 +229,6 @@ test.describe("Teams page", () => {
});
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Create Team" }).click();
await expect(page.getByRole("heading", { name: "Create Team" })).toBeVisible();
@@ -309,7 +305,6 @@ test.describe("Teams page", () => {
});
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Create Team" }).click();
@@ -391,7 +386,6 @@ test.describe("Teams page", () => {
});
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Create Team" }).click();
@@ -475,8 +469,16 @@ test.describe("Teams page", () => {
}
});
+ // Stub the directory search so typing never sends a real request to the preview server.
+ await page.route("**/auth/email/admin/users?*", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ users: [] }),
+ });
+ });
+
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Create Team" }).click();
await page.locator("#team-name").fill("Team With Invite");
@@ -508,7 +510,6 @@ test.describe("Teams page", () => {
});
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Create Team" }).click();
@@ -574,7 +575,6 @@ test.describe("Teams page", () => {
});
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Create Team" }).click();
@@ -637,7 +637,6 @@ test.describe("Teams page", () => {
});
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for Engineering" }).click();
await page.getByRole("menuitem", { name: "Edit" }).click();
@@ -671,7 +670,6 @@ test.describe("Teams page", () => {
});
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for Engineering" }).click();
await page.getByRole("menuitem", { name: "Edit" }).click();
@@ -694,7 +692,6 @@ test.describe("Teams page", () => {
});
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for Engineering" }).click();
await page.getByRole("menuitem", { name: "Edit" }).click();
@@ -723,7 +720,6 @@ test.describe("Teams page", () => {
});
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for Engineering" }).click();
await page.getByRole("menuitem", { name: "Manage Members" }).click();
@@ -784,11 +780,9 @@ test.describe("Teams page", () => {
});
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for Engineering" }).click();
await page.getByRole("menuitem", { name: "Manage Members" }).click();
-
await page.getByRole("button", { name: "Add member" }).click();
await pickComboboxOption(
page,
@@ -862,12 +856,19 @@ test.describe("Teams page", () => {
}
});
+ // Stub the directory search so typing never sends a real request to the preview server.
+ await page.route("**/auth/email/admin/users?*", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ users: [] }),
+ });
+ });
+
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for Engineering" }).click();
await page.getByRole("menuitem", { name: "Manage Members" }).click();
-
await page.getByRole("button", { name: "Add member" }).click();
// No matching directory entry — commit the raw email via "Use ...".
await enterCustomComboboxEmail(
@@ -939,12 +940,19 @@ test.describe("Teams page", () => {
}
});
+ // Stub the directory search so typing never sends a real request to the preview server.
+ await page.route("**/auth/email/admin/users?*", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ users: [] }),
+ });
+ });
+
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for Engineering" }).click();
await page.getByRole("menuitem", { name: "Manage Members" }).click();
-
await page.getByRole("button", { name: "Add member" }).click();
await enterCustomComboboxEmail(
page,
@@ -1001,7 +1009,6 @@ test.describe("Teams page", () => {
);
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for Engineering" }).click();
await page.getByRole("menuitem", { name: "Manage Members" }).click();
@@ -1049,7 +1056,6 @@ test.describe("Teams page", () => {
);
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for Engineering" }).click();
await page.getByRole("menuitem", { name: "Manage Members" }).click();
@@ -1112,7 +1118,6 @@ test.describe("Teams page", () => {
});
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for Engineering" }).click();
await page.getByRole("menuitem", { name: "Manage Members" }).click();
@@ -1163,7 +1168,6 @@ test.describe("Teams page", () => {
});
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for Engineering" }).click();
await page.getByRole("menuitem", { name: "Manage Members" }).click();
@@ -1210,7 +1214,6 @@ test.describe("Teams page", () => {
});
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for Engineering" }).click();
await page.getByRole("menuitem", { name: "Manage Members" }).click();
@@ -1253,7 +1256,6 @@ test.describe("Teams page", () => {
});
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await expect(page.getByText("Engineering")).toBeVisible();
await expect(page.getByText("Public Team")).toBeVisible();
@@ -1287,7 +1289,6 @@ test.describe("Teams page", () => {
});
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for Engineering" }).click();
await page.getByRole("menuitem", { name: "Delete" }).click();
@@ -1324,7 +1325,6 @@ test.describe("Teams page", () => {
});
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await expect(page.getByText("Engineering")).toBeVisible();
@@ -1353,7 +1353,6 @@ test.describe("Teams page", () => {
});
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for Engineering" }).click();
await page.getByRole("menuitem", { name: "Delete" }).click();
@@ -1390,7 +1389,6 @@ test.describe("Teams page", () => {
});
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Create Team" }).click();
await page.locator("#team-name").fill("Duplicate Team");
@@ -1421,7 +1419,6 @@ test.describe("Teams page", () => {
});
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for Engineering" }).click();
await page.getByRole("menuitem", { name: "Edit" }).click();
@@ -1469,7 +1466,6 @@ test.describe("Teams page", () => {
});
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Create Team" }).click();
await page.locator("#team-name").fill("Slow Team");
@@ -1499,7 +1495,6 @@ test.describe("Teams page", () => {
});
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
// aria-labels on the row's action buttons also contain the long name, so
// scope to visible text (getByText) rather than accessible name (getByRole)
@@ -1532,7 +1527,6 @@ test.describe("Teams page", () => {
});
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for Engineering" }).click();
await page.getByRole("menuitem", { name: "Manage Members" }).click();
@@ -1567,7 +1561,6 @@ test.describe("Teams page", () => {
});
await page.goto(APP.TEAMS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for Engineering" }).click();
await page.getByRole("menuitem", { name: "Manage Members" }).click();
diff --git a/e2e/tokens.spec.ts b/e2e/tokens.spec.ts
index d34cf988..9eb51990 100644
--- a/e2e/tokens.spec.ts
+++ b/e2e/tokens.spec.ts
@@ -67,7 +67,6 @@ test.describe("API Tokens", () => {
});
await page.goto(TOKENS_TAB);
- await page.waitForLoadState("networkidle");
await expect(page.getByRole("tab", { name: "API tokens" })).toBeVisible();
await expect(page.getByText("CI token")).toBeVisible();
@@ -85,7 +84,6 @@ test.describe("API Tokens", () => {
});
await page.goto(TOKENS_TAB);
- await page.waitForLoadState("networkidle");
await expect(page.getByRole("heading", { name: "Generate API token" })).toBeVisible();
await expect(page.getByRole("button", { name: "Generate token" })).toBeVisible();
@@ -118,7 +116,6 @@ test.describe("API Tokens", () => {
});
await page.goto(TOKENS_TAB);
- await page.waitForLoadState("networkidle");
// Empty state -> open the create form.
await page.getByRole("button", { name: "Generate token" }).click();
@@ -160,7 +157,6 @@ test.describe("API Tokens", () => {
});
await page.goto(TOKENS_TAB);
- await page.waitForLoadState("networkidle");
await expect(page.getByText("CI token")).toBeVisible();
diff --git a/e2e/tools.spec.ts b/e2e/tools.spec.ts
index 61f9a49c..1ec6c06d 100644
--- a/e2e/tools.spec.ts
+++ b/e2e/tools.spec.ts
@@ -113,7 +113,6 @@ test.describe("Tools page", () => {
});
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
await expect(page.getByText("Add tools")).toBeVisible();
await expect(
@@ -132,7 +131,6 @@ test.describe("Tools page", () => {
});
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
await expect(page.getByRole("heading", { name: "Tools" })).toBeVisible();
await expect(page.getByText("Add tools")).not.toBeVisible();
@@ -148,7 +146,6 @@ test.describe("Tools page", () => {
});
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
// Click the Add tools card
await page.getByText("Add tools").click();
@@ -166,7 +163,6 @@ test.describe("Tools page", () => {
});
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
await expect(page.getByRole("heading", { name: "Tools" })).toBeVisible();
@@ -191,7 +187,6 @@ test.describe("Tools page", () => {
});
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
await expect(page.getByRole("alert")).toBeVisible();
await expect(page.getByText("Error loading tools")).toBeVisible();
@@ -211,7 +206,6 @@ test.describe("Tools page", () => {
});
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
await expect(page.getByText("big-gateway")).toBeVisible();
await expect(page.getByText("10 tools")).toBeVisible();
@@ -239,7 +233,6 @@ test.describe("Tools page", () => {
});
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
// The kebab menu for the long-named group must stay visible.
const kebab = page.getByRole("button", { name: `More options for ${longSlug}` });
@@ -276,7 +269,6 @@ test.describe("Tools page", () => {
});
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "More options for github-server" }).click();
@@ -293,7 +285,6 @@ test.describe("Tools page", () => {
});
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "More options for github-server" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -349,7 +340,6 @@ test.describe("Tools page", () => {
});
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "More options for github-server" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -429,7 +419,6 @@ test.describe("Tools page", () => {
});
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
const panel = await openToolDetails(page, "github-server");
await expect(panel.getByText("MCP version 2025-11-25")).toBeVisible();
@@ -490,7 +479,6 @@ test.describe("Tools page", () => {
});
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
const panel = await openToolDetails(page, "local-gateway");
await panel.getByRole("switch", { name: "Live invocation" }).click();
@@ -555,7 +543,6 @@ test.describe("Tools page", () => {
});
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
const panel = await openToolDetails(page, "github-server");
await panel.getByRole("switch", { name: "Live invocation" }).click();
@@ -590,7 +577,6 @@ test.describe("Tools page", () => {
await routeToolsList(page, [liveTool]);
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
const panel = await openToolDetails(page, "github-server");
await expect(panel.getByRole("button", { name: "Preview", exact: true })).toBeVisible();
@@ -607,7 +593,6 @@ test.describe("Tools page", () => {
await routeToolsList(page, [liveTool]);
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
const panel = await openToolDetails(page, "github-server");
await expect(panel.getByRole("button", { name: "Preview", exact: true })).toBeVisible();
@@ -623,7 +608,6 @@ test.describe("Tools page", () => {
await routeToolsList(page, [federatedTool]);
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
const panel = await openToolDetails(page, "github-server");
await expect(
@@ -659,7 +643,6 @@ test.describe("Tools page", () => {
});
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "More options for github-server" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -691,7 +674,6 @@ test.describe("Tools page", () => {
});
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "More options for github-server" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -726,7 +708,6 @@ test.describe("Tools page", () => {
});
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "More options for github-server" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -778,7 +759,6 @@ test.describe("Tools page", () => {
});
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "More options for github-server" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -812,7 +792,6 @@ test.describe("Tools page", () => {
});
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "More options for github-server" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -852,7 +831,6 @@ test.describe("Tools page", () => {
});
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "More options for solo-gateway" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -900,7 +878,6 @@ test.describe("Tools page", () => {
});
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "More options for multi-gw" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -951,7 +928,6 @@ test.describe("Tools page", () => {
});
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "More options for rb-gateway" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -997,7 +973,6 @@ test.describe("Tools page", () => {
});
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
await expect(page.getByText("lone-gateway")).toBeVisible();
await expect(page.getByText("github-server")).toBeVisible();
@@ -1031,7 +1006,6 @@ test.describe("Tools page", () => {
});
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
const addToolsCard = page.getByRole("button").filter({ hasText: "Add tools" });
await addToolsCard.focus();
@@ -1062,7 +1036,6 @@ test.describe("Tools page", () => {
});
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
await openAddToolForm(page);
await fillToolBasics(page, "list_users", "https://api.example.com/users");
@@ -1096,7 +1069,6 @@ test.describe("Tools page", () => {
});
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
await openAddToolForm(page);
await fillToolBasics(page, "get_user", "https://api.example.com/users/1");
@@ -1124,7 +1096,6 @@ test.describe("Tools page", () => {
});
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
await openAddToolForm(page);
await fillToolBasics(page, "denied_tool", "https://api.example.com/denied");
@@ -1211,7 +1182,6 @@ test.describe("Tools page", () => {
});
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
await openAddToolForm(page);
await fillToolBasics(page, "auth_tool", "https://api.example.com/secure");
@@ -1263,7 +1233,6 @@ test.describe("Tools page", () => {
});
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "More options for edit-gw" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -1304,7 +1273,6 @@ test.describe("Tools page", () => {
await routeToolsList(page, [TOOL]);
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "More options for schema-gw" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -1356,7 +1324,6 @@ test.describe("Tools page", () => {
});
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "More options for toggle-gw" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -1406,7 +1373,6 @@ test.describe("Tools page", () => {
});
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "More options for activate-gw" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -1456,7 +1422,6 @@ test.describe("Tools page", () => {
});
await page.goto(APP.TOOLS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "More options for fail-gw" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
diff --git a/e2e/users.spec.ts b/e2e/users.spec.ts
index 1b815da5..8b845c5d 100644
--- a/e2e/users.spec.ts
+++ b/e2e/users.spec.ts
@@ -58,7 +58,6 @@ test.describe("Users page", () => {
});
await page.goto(APP.USERS);
- await page.waitForLoadState("networkidle");
const main = page.getByRole("main");
await expect(page.getByRole("heading", { name: "Users", exact: true })).toBeVisible();
@@ -114,7 +113,6 @@ test.describe("Users page", () => {
});
await page.goto(APP.USERS);
- await page.waitForLoadState("networkidle");
await expect(page.getByText("John Doe")).toBeVisible();
await expect(page.getByText("Jane Smith")).not.toBeVisible();
@@ -160,7 +158,6 @@ test.describe("Users page", () => {
});
await page.goto(APP.USERS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Create User" }).click();
@@ -188,7 +185,6 @@ test.describe("Users page", () => {
});
await page.goto(APP.USERS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Create User" }).click();
await expect(page.getByRole("heading", { name: "Create User" })).toBeVisible();
});
@@ -280,7 +276,6 @@ test.describe("Users page", () => {
});
await page.goto(APP.USERS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for John Doe" }).click();
await page.getByRole("menuitem", { name: "Edit" }).click();
@@ -311,7 +306,6 @@ test.describe("Users page", () => {
});
await page.goto(APP.USERS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for John Doe" }).click();
await page.getByRole("menuitem", { name: "Edit" }).click();
@@ -380,7 +374,6 @@ test.describe("Users page", () => {
});
await page.goto(APP.USERS);
- await page.waitForLoadState("networkidle");
await expect(page.getByText("John Doe")).toBeVisible();
await expect(page.getByText("Jane Smith")).toBeVisible();
@@ -419,7 +412,6 @@ test.describe("Users page", () => {
});
await page.goto(APP.USERS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for John Doe" }).click();
await page.getByRole("menuitem", { name: "Delete" }).click();
@@ -456,7 +448,6 @@ test.describe("Users page", () => {
});
await page.goto(APP.USERS);
- await page.waitForLoadState("networkidle");
await expect(page.getByText("John Doe")).toBeVisible();
@@ -523,7 +514,6 @@ test.describe("Users page", () => {
);
await page.goto(APP.USERS);
- await page.waitForLoadState("networkidle");
// Located by email, not by selfName -- an empty/whitespace-only real
// full_name renders as "Unnamed User" (see above), which would make a
diff --git a/e2e/virtual-servers.spec.ts b/e2e/virtual-servers.spec.ts
index 2d65d95d..75923b69 100644
--- a/e2e/virtual-servers.spec.ts
+++ b/e2e/virtual-servers.spec.ts
@@ -216,7 +216,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
await expect(page.getByRole("heading", { name: "Virtual servers" })).toBeVisible();
const mainContent = page.getByRole("main");
@@ -243,7 +242,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
await expect(page.getByRole("heading", { name: "Virtual servers" })).toBeVisible();
await expect(
@@ -261,7 +259,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: /Create server Make external sources/i }).click();
@@ -459,7 +456,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
// Check for virtual servers heading
await expect(page.getByRole("heading", { name: "Virtual servers" })).toBeVisible();
@@ -483,7 +479,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
await expect(page.getByText("testVS")).toBeVisible();
@@ -511,7 +506,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for testVS" }).click();
@@ -553,7 +547,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
const card = page.getByTestId("virtual-server-card").filter({ hasText: "testVS" });
await expect(card.getByTestId("status-indicator")).toHaveClass(/bg-tool-status-inactive/);
@@ -593,7 +586,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
const card = page.getByTestId("virtual-server-card").filter({ hasText: "testVS" });
await page.getByRole("button", { name: "Actions for testVS" }).click();
@@ -646,7 +638,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for testVS" }).click();
await page.getByRole("menuitem", { name: "Delete" }).click();
@@ -688,7 +679,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for testVS" }).click();
await page.getByRole("menuitem", { name: "Delete" }).click();
@@ -724,7 +714,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
await expect(page.getByText("testVS")).toBeVisible();
@@ -769,7 +758,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for testVS" }).click();
await page.getByRole("menuitem", { name: "Delete" }).click();
@@ -820,7 +808,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for testVS" }).click();
await page.getByRole("menuitem", { name: "Delete" }).click();
@@ -865,7 +852,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for testVS" }).click();
await page.getByRole("menuitem", { name: "Delete" }).click();
@@ -892,7 +878,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
await expect(
page.getByTestId("virtual-server-card").filter({ hasText: "testVS" }),
@@ -934,7 +919,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
await expect(
page.getByTestId("virtual-server-card").filter({ hasText: "testVS" }),
@@ -994,7 +978,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for testVS" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -1004,7 +987,6 @@ test.describe("Virtual Servers page", () => {
await page.keyboard.press("Escape"); // drawer is a full-height overlay; must close it before clicking table row actions
await expect(detailsPanel).toHaveCount(0);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for testVS" }).click();
await page.getByRole("menuitem", { name: "Delete" }).click();
@@ -1057,7 +1039,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for testVS" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -1067,7 +1048,6 @@ test.describe("Virtual Servers page", () => {
await page.keyboard.press("Escape"); // drawer is a full-height overlay; must close it before clicking table row actions
await expect(detailsPanel).toHaveCount(0);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for testVS" }).click();
await page.getByRole("menuitem", { name: "Delete" }).click();
@@ -1113,7 +1093,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for testVS" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -1125,8 +1104,6 @@ test.describe("Virtual Servers page", () => {
await expect(detailsPanel).toHaveCount(0);
- await page.waitForLoadState("networkidle");
-
await page.getByRole("button", { name: "Actions for testVS" }).click();
await page.getByRole("menuitem", { name: "Delete" }).click();
@@ -1160,7 +1137,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for testVS" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -1271,7 +1247,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for testVS" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -1324,7 +1299,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for testVS" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -1370,7 +1344,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for testVS" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();
@@ -1436,7 +1409,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
const panel = await openVirtualServerToolTest(page);
await expect(panel.getByRole("button", { name: "Preview" })).toBeVisible();
@@ -1508,7 +1480,6 @@ test.describe("Virtual Servers page", () => {
await routeVirtualServerTryIt(page, [makeTryItTool()]);
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
const panel = await openVirtualServerToolTest(page);
await expect(panel.getByRole("switch", { name: "Live invocation" })).toHaveCount(0);
@@ -1520,7 +1491,6 @@ test.describe("Virtual Servers page", () => {
test("restores focus after keyboard navigation through tool testing", async ({ page }) => {
await routeVirtualServerTryIt(page, [makeTryItTool()]);
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Actions for testVS" }).focus();
await page.keyboard.press("Enter");
@@ -1546,7 +1516,6 @@ test.describe("Virtual Servers page", () => {
await routeVirtualServerTryIt(page, [makeTryItTool()]);
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
const panel = await openVirtualServerToolTest(page);
await expect(panel.getByRole("switch", { name: "Live invocation" })).toHaveCount(0);
@@ -1566,6 +1535,7 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
+ // networkidle: the absence check below is only meaningful once the page settles.
await page.waitForLoadState("networkidle");
await expect(page.getByRole("button", { name: /Open testVS \(coming soon\)/ })).toHaveCount(0);
@@ -1582,6 +1552,7 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
+ // networkidle: the absence check below is only meaningful once the page settles.
await page.waitForLoadState("networkidle");
await expect(page.getByRole("button", { name: "Virtual server actions" })).toHaveCount(0);
@@ -1601,7 +1572,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
// Check for error message
await expect(page.getByRole("alert")).toBeVisible();
@@ -1620,7 +1590,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
const card = page.getByTestId("virtual-server-card").filter({ hasText: "testVS" });
@@ -1649,7 +1618,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
await expect(page.getByText("testVS")).toBeVisible();
await expect(page.getByText("Production Server")).toBeVisible();
@@ -1683,7 +1651,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
// Cards are uniform now, so empty servers are no longer sorted to the end
// to keep a full-width row from splitting the grid.
@@ -1707,7 +1674,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
// Focus the create server card
const connectCard = page.getByRole("button", {
@@ -1737,7 +1703,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
const card = page.getByTestId("virtual-server-card").filter({ hasText: "testVS" });
await expect(card.getByTestId("last-updated")).toBeVisible();
@@ -1761,7 +1726,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
const card = page.getByTestId("virtual-server-card").filter({ hasText: "testVS" });
@@ -1791,7 +1755,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto(APP.GATEWAYS);
- await page.waitForLoadState("networkidle");
await page
.getByTestId("virtual-server-card")
@@ -1872,7 +1835,6 @@ test.describe("Virtual Servers page", () => {
});
await page.goto("/app/gateways/create-server");
- await page.waitForLoadState("networkidle");
await expect(page.getByRole("radio", { name: "Internal" })).toBeChecked();
await page.getByLabel("Name").fill("MCP backed server");
diff --git a/package.json b/package.json
index 12b9a3ce..6e3f80a3 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,7 @@
"scripts": {
"predev": "npm ls --depth=0 >/dev/null 2>&1 || (echo 'Dependencies missing or out of date at repo root — run: npm install' >&2 && exit 1)",
"dev": "vite",
- "dev:e2e": "vite --base=/ --port 5173 --strictPort",
+ "playwright:serve": "vite build --base=/ --sourcemap && vite preview --base=/ --port 5173 --strictPort",
"predev:bff": "npm ls --prefix server --depth=0 >/dev/null 2>&1 || (echo 'Dependencies missing or out of date in server/ — run: npm install --prefix server' >&2 && exit 1)",
"dev:bff": "VITE_DEV_SERVER_URL=http://localhost:5173 npm run dev --prefix server",
"predev:all": "npm run predev --silent && ([ -f openapi.json ] || (echo 'openapi.json is missing at repo root — api:generate reads from it and will fail with a less helpful error. Restore it (e.g. git checkout main -- openapi.json), see README.md#api-types' >&2 && exit 1)) && npm run api:generate",
@@ -27,7 +27,7 @@
"test:ui": "vitest --ui",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
- "e2e": "playwright test",
+ "playwright": "playwright test",
"e2e:docker:up": "docker compose -f docker-compose.e2e.yml up -d --wait --build",
"e2e:docker:seed": "node --env-file-if-exists=.env --experimental-strip-types e2e/seed/seed.ts",
"e2e:docker:down": "docker compose -f docker-compose.e2e.yml down -v",
@@ -35,11 +35,11 @@
"e2e:docker:tls:up": "docker compose -f docker-compose.e2e.yml -f docker-compose.tls.yml -f docker-compose.e2e-tls.yml up -d --wait --build",
"e2e:docker:tls:down": "docker compose -f docker-compose.e2e.yml -f docker-compose.tls.yml -f docker-compose.e2e-tls.yml down -v",
"e2e:docker:tls": "(npm run e2e:docker:tls:up && E2E_GATEWAY_URL=https://localhost:4444 NODE_EXTRA_CA_CERTS=$PWD/certs/cert.pem node --env-file-if-exists=.env --experimental-strip-types e2e/seed/seed.ts && E2E_REAL_API=true PLAYWRIGHT_BASE_URL=https://localhost:8443 PLAYWRIGHT_SKIP_WEBSERVER=1 node --env-file-if-exists=.env node_modules/.bin/playwright test --reporter=list; s=$?; npm run e2e:docker:tls:down; exit $s)",
- "e2e:html": "playwright test --reporter=html",
- "e2e:ui": "playwright test --ui",
- "e2e:debug": "playwright test --debug",
- "e2e:install": "playwright install --with-deps chromium",
- "e2e:report": "playwright show-report",
+ "playwright:html": "playwright test --reporter=html",
+ "playwright:ui": "playwright test --ui",
+ "playwright:debug": "playwright test --debug",
+ "playwright:install": "playwright install --with-deps chromium",
+ "playwright:report": "playwright show-report",
"i18n:extract": "formatjs extract 'src/**/*.{ts,tsx}' --out-file src/i18n/extracted.json --id-interpolation-pattern '[sha512:contenthash:base64:6]'",
"i18n:compile": "formatjs compile-folder --ast src/i18n/locales src/i18n/compiled",
"openapi:refresh": "bash scripts/refresh-openapi.sh",
diff --git a/playwright.config.ts b/playwright.config.ts
index 4ed27bbb..bd0d0b88 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -36,7 +36,7 @@ export default defineConfig({
ignoreHTTPSErrors: BASE_URL.startsWith("https"),
trace: "on-first-retry",
screenshot: "only-on-failure",
- video: "retain-on-failure",
+ video: IS_CI ? "off" : "retain-on-failure",
actionTimeout: 10_000,
navigationTimeout: 15_000,
extraHTTPHeaders: {
@@ -56,7 +56,8 @@ export default defineConfig({
webServer: process.env.PLAYWRIGHT_SKIP_WEBSERVER
? undefined
: {
- command: "npm run dev:e2e",
+ // Built bundle, not the dev server: ~40% faster, half the CPU (no per-module transforms).
+ command: "npm run playwright:serve",
url: BASE_URL,
reuseExistingServer: REUSE_EXISTING_SERVER,
timeout: 120_000,
diff --git a/src/components/ui/combobox.test.tsx b/src/components/ui/combobox.test.tsx
index 845c6e9e..5fd9330d 100644
--- a/src/components/ui/combobox.test.tsx
+++ b/src/components/ui/combobox.test.tsx
@@ -91,6 +91,21 @@ describe("Combobox", () => {
expect(onValueChange).toHaveBeenCalledWith("new@example.com");
});
+ it("keeps the typed search when focus bounces away and back while open", async () => {
+ const user = userEvent.setup();
+ render();
+ const input = screen.getByRole("combobox");
+
+ await user.click(input);
+ await user.keyboard("new@example.com");
+ // A closing menu returning focus to its trigger, then a dialog's focus trap pulling it back.
+ fireEvent.blur(input);
+ fireEvent.focus(input);
+
+ expect(input).toHaveValue("new@example.com");
+ expect(screen.getByRole("option", { name: 'Use "new@example.com"' })).toBeInTheDocument();
+ });
+
it("shows the empty text and no custom entry when allowCustomValue is false", async () => {
const user = userEvent.setup();
render();
diff --git a/src/components/ui/combobox.tsx b/src/components/ui/combobox.tsx
index 16af90e8..bad87e6a 100644
--- a/src/components/ui/combobox.tsx
+++ b/src/components/ui/combobox.tsx
@@ -98,7 +98,7 @@ export function Combobox({
const handleOpen = () => {
clearBlurTimeout();
- if (!disabled) {
+ if (!disabled && !open) {
setOpen(true);
setSearchValue("");
}