Skip to content
Closed
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
30 changes: 24 additions & 6 deletions .github/workflows/ci-frontend-v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,12 @@ jobs:
working-directory: frontend
run: node scripts/check-v1-styles.mjs

# The v1 unit tests. P0-T08 (UN-4329) replaces this with the frontend-v1
# unit group.
# The v1 unit tests: the rig's `frontend-v1-unit` group (tests/groups.yaml),
# run with the same config so CI and the rig cannot disagree. The config
# fails the run below v1's coverage threshold. Run here rather than through
# ci-test.yaml because its tiers skip frontend-only PRs. Cloud v1 plugins
# (vitest.v1-plugins.config.mts) are gated by the cloud mirror, not here:
# OSS has none.
unit:
name: v1 · Unit tests
needs: changes
Expand All @@ -159,7 +163,18 @@ jobs:
- name: Run v1 unit tests
working-directory: frontend
# The lockfile's vitest, never a fetched one.
run: ./node_modules/.bin/vitest run --dir src/v1 --passWithNoTests
run: ./node_modules/.bin/vitest run --config vitest.v1.config.mts

# The summary is in the log; the lcov report is for reading what a
# failed threshold missed.
- name: Upload v1 coverage
if: ${{ !cancelled() }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: frontend-v1-coverage
path: frontend/coverage/v1/
if-no-files-found: ignore
retention-days: 7

# One production build, then the bundle guard (no v1 chunk loads v0 or
# antd) and the separation guard (no enterprise plugin code in this repo).
Expand Down Expand Up @@ -194,8 +209,11 @@ jobs:
working-directory: frontend
run: node scripts/check-v1-separation.mjs

# Proves every guard above fails and passes when it should (node --test,
# Node 22 APIs). Spawns the real tools, two small Vite builds included.
# Proves every guard above fails and passes when it should, and that the
# unit harness (src/v1/test) fails a test that sends an unmocked request,
# and that the Unit tests check fails below the coverage threshold and runs
# the rig group's config (node --test, Node 22 APIs). Spawns the real tools, two small
# Vite builds included.
guard-fixtures:
name: v1 · Guard fixtures
needs: changes
Expand All @@ -221,4 +239,4 @@ jobs:

- name: Run the guard fixtures
working-directory: frontend
run: node --test test-fixtures/v1-boundary/boundary.fixtures.mjs
run: node --test test-fixtures/v1-boundary/boundary.fixtures.mjs test-fixtures/v1-harness/harness.fixtures.mjs test-fixtures/v1-unit-gate/unit-gate.fixtures.mjs
210 changes: 201 additions & 9 deletions frontend/bun.lock

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@rjsf/utils": "^5.8.1",
"@rjsf/validator-ajv8": "^5.8.1",
"@stripe/stripe-js": "^4.2.0",
"@tanstack/react-query": "5.104.5",
"@tanstack/react-table": "^8.21.3",
"axios": "1.16.0",
"class-variance-authority": "^0.7.1",
Expand Down Expand Up @@ -81,7 +82,7 @@
"tokens:v1": "bun scripts/build-v1-tokens.mjs",
"typecheck:v1": "node scripts/check-v1.mjs --files && tsc -p src/v1/tsconfig.json",
"lint:v1": "biome lint --error-on-warnings src/v1/ && node scripts/check-v1.mjs --code && node scripts/check-v1-styles.mjs",
"test:v1": "vitest run --dir src/v1 --passWithNoTests && node --test test-fixtures/v1-boundary/boundary.fixtures.mjs",
"test:v1": "vitest run --config vitest.v1.config.mts && vitest run --config vitest.v1-plugins.config.mts && node --test test-fixtures/v1-boundary/boundary.fixtures.mjs test-fixtures/v1-harness/harness.fixtures.mjs test-fixtures/v1-unit-gate/unit-gate.fixtures.mjs",
"seam:v1": "node scripts/check-v1-seam.mjs --base origin/main",
"separation:v1": "node scripts/check-v1-separation.mjs",
"bundle:v1": "node scripts/check-v1-bundle.mjs"
Expand Down Expand Up @@ -112,9 +113,12 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^4.4.0",
"@vitest/coverage-v8": "3.2.6",
"autoprefixer": "^10.5.4",
"axe-core": "4.14.0",
"baseline-browser-mapping": "^2.9.19",
"happy-dom": "^20.10.2",
"msw": "2.15.0",
"postcss": "^8.5.23",
"postcss-value-parser": "^4.2.0",
"tailwindcss": "^4",
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ Every folder may import from itself. Test files (`*.test.ts(x)`,
| `bun run typecheck:v1` | TypeScript-only files, styles only in `styles/`, allowed file types only, no symlinks, folder taxonomy; then `tsc` with `src/v1/tsconfig.json` |
| `bun run lint:v1` | Biome on `src/v1/` (warnings fail), then imports in code and in `styles/` CSS (nothing outside v1, folder contract, and v0 reaching v1 only through `@v1`), PropTypes and `ant-` checks, then the styling policy (S1 to S8 above) |
| `bun run tokens:v1` | Regenerates `styles/tokens.css` from `styles/tokens.json` (via `styles/tokens.ts`; runs under bun, which imports the TypeScript source directly) |
| `bun run test:v1` | v1 unit tests, then the guardrail fixtures in `test-fixtures/v1-boundary/` |
| `bun run test:v1` | v1 unit tests (`vitest.v1.config.mts`, then cloud plugins with `vitest.v1-plugins.config.mts`) with v1's own coverage threshold, then the guardrail fixtures in `test-fixtures/v1-boundary/`, `test-fixtures/v1-harness/` and `test-fixtures/v1-unit-gate/` (the coverage threshold fails a run, and CI runs the rig group's config). The harness is documented in `test/README.md` |
| `bun run seam:v1` | Seam guard: v1 work changes v0 (`src/` outside v1, `public/`) only at the files in `scripts/v1-seam-allowlist.json`, compared with `origin/main` |
| `bun run separation:v1` | No enterprise plugin code: no folder under `plugins/` is tracked in the public repository |
| `bun run bundle:v1` | Production build, then no v1 chunk (or a chunk it loads) holds a v0 module or antd |
Expand Down
175 changes: 175 additions & 0 deletions frontend/src/v1/test/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
# v1 test harness

Shared helpers for v1 unit tests, imported as `@v1/test`. Only test files
(`*.test.ts(x)`, `*.spec.ts(x)`) and this folder may import it.

## Running

| Command | Runs |
| --- | --- |
| `bun run test:v1` | `vitest.v1.config.mts` (src/v1 minus `plugins/`), `vitest.v1-plugins.config.mts` (cloud v1 plugins), then the guardrail fixtures |
| `npx vitest --config vitest.v1.config.mts` | v1 in watch mode |
| `python -m tests.rig run frontend-v1-unit` | the rig group CI runs |

v0 (`bun run test`, the rig's `frontend` group) excludes `src/v1/`, so a v1
result never changes a v0 one and the reverse. Cloud v1 plugins
(`src/v1/plugins/<id>/`) run in the cloud overlay's `frontend-v1-unit-cloud`
group, never in `frontend-v1-unit`.

Each v1 config enforces its own coverage threshold, 80% on lines, statements,
functions and branches. It writes to `coverage/v1` (and `coverage/v1-plugins`)
as text summary, `json-summary` and `lcov`. A miss fails the v1 group only.
Raise the threshold in `vitest.v1.config.mts` as v1 grows. Never lower it to
land a change.

What the config does for every test (`setup.ts`):

- `globals: false`. Import `it`, `expect` and `vi` from `vitest`.
- jest-dom matchers, Testing Library cleanup, and removal of `#v1-portal`.
- `unstubGlobals`, `unstubEnvs`, `restoreMocks`. A `vi.stubGlobal`,
`vi.stubEnv` or `vi.spyOn` is undone after each test, so tests need no
`afterEach` for it.
- MSW handlers reset after each test, and **the test fails if it made a
request that no handler matched** (see below).
- No v0 transforms. A missing module fails to import (v1 has no optional
plugin imports), and `@/` does not resolve.

## Rendering

```tsx
import { screen } from "@testing-library/react";
import { makeSession, renderV1 } from "@v1/test";

const { user, queryClient, portal, session } = renderV1(<AdapterList />, {
route: "/adapters/llm", // MemoryRouter start; default "/"
path: "/adapters/:type", // mount under a pattern so useParams works
session: makeSession({ role: "user" }), // null renders signed out
distribution: "cloud", // "oss" (default) | "cloud" | "onprem"
theme: "dark", // "light" (default) | "dark" | "unstract"
});
await user.click(screen.getByRole("button", { name: "Add" }));
```

`renderV1` returns everything Testing Library's `render` does, plus:

- `user`: a `userEvent.setup()` instance. Pass `userEventOptions`, for example
`{ advanceTimers: vi.advanceTimersByTime }` with fake timers.
- `queryClient`: a fresh client per render, with no retries, no refetch on
focus and no garbage collection mid-test. Pass `queryClient` to share one.
- `portal`: the `#v1-portal` container, themed like the root.
- `session`, `distribution`, `theme`: what the test rendered with.

`renderHookV1(hook, { initialProps, ...sameOptions })` does the same for hooks.

### Providers, and swapping in the real ones

`providers.tsx` is the only file that knows the provider chain. Each slot is
the app's own provider once its task has landed, and a stand-in until then:

| Slot | Today | Replace with, when it lands |
| --- | --- | --- |
| query | stand-in: `QueryClientProvider` over `createTestQueryClient()` | the data layer's provider (P0-T05, UN-4313) |
| router | stand-in: `MemoryRouter` (+ `Routes` when `path` is set) | the app router in memory mode, with the route registry (P0-T03, UN-4271) |
| theme | **real**: `app/theme/ThemeProvider` (P0-T04, #2399) | — |

To swap one, change its component in `PROVIDER_SLOTS`. It receives the
resolved `ProviderContext` (query client, route, path, session, distribution,
theme). Global state a provider reads goes in `prepareProviders` (set before
render) and `resetProviders` (undone after each test). The theme slot shows
the pattern: the preference is stored, then `ThemeProvider` hydrates it, and
the store and its storage key are reset afterwards. Tests and `renderV1`'s
options stay the same. When a real session store or plugin registry exists,
seed it from `context.session` and `context.distribution` there too.

## Network

Every HTTP request (fetch and XHR, so axios too) and every WebSocket goes
through MSW. Register handlers per test with `mockApi` (`http.*` for HTTP,
`ws.link(url)` for sockets). They are dropped after the test.

```ts
import { apiPath, DEFAULT_SESSION, HttpResponse, http, mockApi } from "@v1/test";

mockApi(
http.get<never, never, Adapter[]>(apiPath(DEFAULT_SESSION, "/adapter/"), () =>
HttpResponse.json(adapterFixtures.list),
),
);
```

`apiPath(session, "/adapter/")` builds the org-scoped
`/api/v1/unstract/<org id>/adapter/`.

**An unmocked request fails the test**, even when the code under test catches
the error. An HTTP request gets a network error and a WebSocket is closed
(code 1011), so nothing leaves the process. The test then fails with:

```text
Error: Unmocked request (add a handler with mockApi() or server.use()):
GET http://localhost:3000/api/v1/unstract/org-1/adapter/?adapter_type=LLM
```

This covers requests made after the last assertion too. Ones started by an
unmount effect fail the test that unmounted. Ones made after a file's last test
(a timer firing during its `afterAll`) fail the file. Catch-all handlers do
this, and they always match last, even after `server.resetHandlers(...)` with
arguments. A test that leaves fake timers on gets real timers back after it.

### Typed fixtures per service module

Each service module in `services/` exports its request and response types.
Its fixtures live in `test/fixtures/<service>.ts`, typed against those types
so a contract change breaks the fixture at typecheck:

```ts
// test/fixtures/adapters.ts
import type { Adapter } from "@v1/services/adapters";
import { apiPath, HttpResponse, http, type TestSession } from "@v1/test";

export const adapterFixtures = {
list: [{ id: "a1", adapter_name: "OpenAI", adapter_type: "LLM" }],
} satisfies Record<string, Adapter[]>;

export const adapterHandlers = (session: TestSession) => [
http.get<never, never, Adapter[]>(apiPath(session, "/adapter/"), () =>
HttpResponse.json(adapterFixtures.list),
),
];
```

## Accessibility

```ts
await expect(container).toHaveNoA11yViolations();
await expectNoA11yViolations(screen.getByRole("dialog"));
const violations = await axeViolations(container); // inspect them yourself
```

These run axe-core. A failure lists each node as `[rule] (impact) selector:
help`, with the rule's URL. `color-contrast` is off (tests load no CSS) and so
is `region` (a component rendered alone is outside a page's landmarks). Pass
`{ rules: { region: { enabled: true } } }` for a whole-page test. Always
`await` the matcher.

## Browser APIs happy-dom does not drive

```ts
const size = installResizeObserver();
size.resize(element, { width: 640, height: 480 });

const view = installIntersectionObserver();
view.intersect(element, true);

const media = mockMatchMedia({ dark: false });
media.setDark(true); // fires (prefers-color-scheme: dark) listeners
```

Each wraps its callbacks in `act()` and is undone after the test.

## Proof the harness fails when it should

`test-fixtures/v1-harness/` runs real vitest processes, under `node --test` in
`bun run test:v1`. The known-bad cases are an unmocked fetch, XHR and
WebSocket, a request on unmount (and which test it fails), a request after the
last test, and `resetHandlers` with arguments. The known-good cases are a mocked
fetch and fake timers left on.
97 changes: 97 additions & 0 deletions frontend/src/v1/test/a11y.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import axe from "axe-core";
import { expect } from "vitest";

/*
* Accessibility assertions on axe-core. Two rules are off by default:
* `color-contrast` (tests load no CSS, so every colour is the browser
* default) and `region` (a component rendered alone is not inside a page's
* landmarks). Pass `rules` to turn either back on for a whole-page test.
*/

export type A11yOptions = axe.RunOptions;

const DEFAULT_RULES: NonNullable<axe.RunOptions["rules"]> = {
"color-contrast": { enabled: false },
region: { enabled: false },
};

// axe-core allows one run at a time ("Axe is already running"), so runs are
// chained: overlapping assertions wait their turn instead of throwing.
let queue: Promise<unknown> = Promise.resolve();

/** Runs axe over `target` and returns its violations. */
export function axeViolations(
target: Element = document.body,
options: A11yOptions = {},
): Promise<axe.Result[]> {
const run = queue.then(async () => {
const results = await axe.run(target, {
...options,
rules: { ...DEFAULT_RULES, ...options.rules },
});
return results.violations;
});
queue = run.catch(() => undefined);
return run;
}

/** One line per failing node: rule, impact, selector and axe's summary. */
export function formatViolations(violations: readonly axe.Result[]): string {
return violations
.flatMap((violation) =>
violation.nodes.map(
(node) =>
` [${violation.id}] (${violation.impact ?? "unknown"}) ${node.target.join(" ")}: ${violation.help}\n ${violation.helpUrl}`,
),
)
.join("\n");
}

/** Throws, listing every violation, when axe finds any in `target`. */
export async function expectNoA11yViolations(
target: Element = document.body,
options: A11yOptions = {},
): Promise<void> {
const violations = await axeViolations(target, options);
if (violations.length > 0) {
throw new Error(
`Expected no accessibility violations, found:\n${formatViolations(violations)}`,
);
}
}

type A11yMatcherResult = { pass: boolean; message: () => string };

async function toHaveNoA11yViolations(
received: unknown,
options: A11yOptions = {},
): Promise<A11yMatcherResult> {
if (!(received instanceof Element)) {
return {
pass: false,
message: () => "toHaveNoA11yViolations expects a DOM element",
};
}
const violations = await axeViolations(received, options);
return {
pass: violations.length === 0,
message: () =>
violations.length === 0
? "Expected accessibility violations, found none"
: `Expected no accessibility violations, found:\n${formatViolations(violations)}`,
};
}

/** Registers `await expect(element).toHaveNoA11yViolations()`; setup.ts calls it. */
export function registerA11yMatchers(): void {
expect.extend({ toHaveNoA11yViolations });
}

interface A11yMatchers<R = unknown> {
toHaveNoA11yViolations(options?: A11yOptions): Promise<R>;
}

declare module "vitest" {
interface Assertion<T> extends A11yMatchers<T> {}
interface AsymmetricMatchersContaining extends A11yMatchers {}
}
Loading
Loading