Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions .github/workflows/client-e2e.yml
Original file line number Diff line number Diff line change
@@ -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
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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` |

Expand Down
21 changes: 11 additions & 10 deletions e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down Expand Up @@ -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.
Expand Down
1 change: 0 additions & 1 deletion e2e/global-search.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down
9 changes: 3 additions & 6 deletions e2e/invitations.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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();

Expand All @@ -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();

Expand All @@ -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();

Expand All @@ -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();

Expand All @@ -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();

Expand Down Expand Up @@ -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.
Expand All @@ -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();

Expand All @@ -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/);

Expand Down
3 changes: 0 additions & 3 deletions e2e/oauth-authorization.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 0 additions & 7 deletions e2e/plugins.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand All @@ -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.
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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(
Expand Down Expand Up @@ -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.");

Expand Down
Loading
Loading