From 07690ea6eafa6a4d5f2958c3d14c6188f0f71665 Mon Sep 17 00:00:00 2001 From: Schnitz <12687466+CptSchnitz@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:14:16 +0300 Subject: [PATCH 01/17] test(auth-ui): add a test harness scoped to the assets feature auth-ui had no test runner. This adds vitest on jsdom with one seam at HTTP: the global fetch is stubbed by the setup file, before any page module loads, because the shared api client resolves its base url at module load behind a top-level await on /config.json. The editor library cannot run under jsdom, so its React wrapper is aliased to a text area that forwards content and changes plus a two-sided diff component. Highlighting and diff rendering are therefore outside component-test coverage; the real library still loads in the tokenizer tests. Include patterns confine the runner to tests/assets. Existing application code gets no tests here. Co-Authored-By: Claude Opus 5 (1M context) --- apps/auth-ui/eslint.config.js | 7 + apps/auth-ui/package.json | 18 +- apps/auth-ui/tests/assets/harness.spec.tsx | 70 +++ .../auth-ui/tests/assets/monaco-loads.spec.ts | 23 + apps/auth-ui/tests/http-stub.ts | 125 ++++ .../tests/mocks/monaco-editor-react.tsx | 74 +++ apps/auth-ui/tests/render.tsx | 39 ++ apps/auth-ui/tests/setup.ts | 56 ++ apps/auth-ui/tsconfig.test.json | 8 + apps/auth-ui/vitest.config.ts | 27 + knip.config.ts | 5 +- pnpm-lock.yaml | 548 +++++++++++++++++- 12 files changed, 983 insertions(+), 17 deletions(-) create mode 100644 apps/auth-ui/tests/assets/harness.spec.tsx create mode 100644 apps/auth-ui/tests/assets/monaco-loads.spec.ts create mode 100644 apps/auth-ui/tests/http-stub.ts create mode 100644 apps/auth-ui/tests/mocks/monaco-editor-react.tsx create mode 100644 apps/auth-ui/tests/render.tsx create mode 100644 apps/auth-ui/tests/setup.ts create mode 100644 apps/auth-ui/tsconfig.test.json create mode 100644 apps/auth-ui/vitest.config.ts diff --git a/apps/auth-ui/eslint.config.js b/apps/auth-ui/eslint.config.js index a31e7316..106475ef 100644 --- a/apps/auth-ui/eslint.config.js +++ b/apps/auth-ui/eslint.config.js @@ -24,5 +24,12 @@ export default tseslint.config( '@typescript-eslint/no-explicit-any': 'off', 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], }, + }, + { + // Test doubles and render helpers are not hot-reloaded. + files: ['tests/**/*.{ts,tsx}'], + rules: { + 'react-refresh/only-export-components': 'off', + }, } ); diff --git a/apps/auth-ui/package.json b/apps/auth-ui/package.json index 48541f10..34b3e108 100644 --- a/apps/auth-ui/package.json +++ b/apps/auth-ui/package.json @@ -10,6 +10,8 @@ "build:docker": "docker buildx build --build-arg APP_NAME=$npm_package_name -f ../../docker/frontend.Dockerfile -t ${DOCKER_REGISTRY:-}${npm_package_name}:${DOCKER_TAG:-latest} ${DOCKER_FLAGS:-} ../..", "build:docker-no-cache": "cross-env DOCKER_FLAGS=--no-cache pnpm run build:docker", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "test": "vitest run", + "test:watch": "vitest", "preview": "vite preview", "knip": "knip --directory ../.. --workspace apps/auth-ui" }, @@ -18,6 +20,7 @@ }, "dependencies": { "@hookform/resolvers": "^5.0.1", + "@monaco-editor/loader": "^1.7.0", "@monaco-editor/react": "^4.7.0", "@radix-ui/react-checkbox": "^1.1.5", "@radix-ui/react-dialog": "^1.1.11", @@ -34,8 +37,8 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^3.6.0", + "lodash": "catalog:", "lucide-react": "^0.488.0", - "@monaco-editor/loader": "^1.7.0", "monaco-editor": "^0.54.0", "next-themes": "^0.4.6", "openapi-fetch": "^0.13.5", @@ -49,24 +52,29 @@ "sonner": "^2.0.3", "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.3", + "tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.2.5", - "zod": "^3.24.4", - "lodash": "catalog:", - "tailwindcss-animate": "^1.0.7" + "zod": "^3.24.4" }, "devDependencies": { "@eslint/js": "^9.21.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "@types/node": "^22.14.1", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^4.3.4", + "auth-openapi": "workspace:*", "eslint": "^9.21.0", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^15.15.0", + "jsdom": "^29.1.1", "typescript": "^5.8.3", "typescript-eslint": "^8.24.1", "vite": "^6.2.6", - "auth-openapi": "workspace:*" + "vitest": "catalog:" } } diff --git a/apps/auth-ui/tests/assets/harness.spec.tsx b/apps/auth-ui/tests/assets/harness.spec.tsx new file mode 100644 index 00000000..34aa9963 --- /dev/null +++ b/apps/auth-ui/tests/assets/harness.spec.tsx @@ -0,0 +1,70 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { describe, expect, it } from 'vitest'; +import { Editor } from '@monaco-editor/react'; +import { $api } from '../../src/fetch'; +import { API_ORIGIN, http } from '../http-stub'; +import { renderRoutes, renderWithProviders } from '../render'; + +const Params = () =>

{useParams().id}

; + +const Assets = () => { + const { data } = $api.useQuery('get', '/asset', { params: { query: { type: 'POLICY' } } }); + return ( + + ); +}; + +describe('test harness', () => { + it('serves requests from the stub and records method, path and query', async () => { + http.on('GET', '/asset', { + body: [{ name: 'authz.rego', value: '', uri: '/', type: 'POLICY', isTemplate: false, version: 1, environment: [], createdAt: '' }], + }); + + renderWithProviders(); + + expect(await screen.findByText('authz.rego')).toBeInTheDocument(); + + const request = http.lastRequestFor('GET', '/asset'); + expect(request?.method).toBe('GET'); + expect(request?.url.startsWith(API_ORIGIN)).toBe(true); + expect(request?.query.get('type')).toBe('POLICY'); + }); + + it('fails loudly on an unstubbed request rather than reaching the network', async () => { + renderWithProviders(); + + await waitFor(() => expect(http.requestsFor('GET', '/asset')).toHaveLength(1)); + expect(screen.queryByRole('listitem')).not.toBeInTheDocument(); + }); + + it('mounts real route objects on a data router, parameters and all', async () => { + const routes = [{ path: 'thing/:id', element: }]; + + renderRoutes(routes, '/thing/authz.rego'); + + expect(await screen.findByText('authz.rego')).toBeInTheDocument(); + }); + + it('renders the editor as a text area that forwards content and changes', async () => { + const Host = () => { + const [value, setValue] = useState('package authz'); + return setValue(next ?? '')} />; + }; + + renderWithProviders(); + + const editor = await screen.findByTestId('monaco-editor'); + expect(editor).toHaveValue('package authz'); + expect(editor).toHaveAttribute('data-language', 'rego'); + + await userEvent.type(editor, '.main'); + expect(editor).toHaveValue('package authz.main'); + }); +}); diff --git a/apps/auth-ui/tests/assets/monaco-loads.spec.ts b/apps/auth-ui/tests/assets/monaco-loads.spec.ts new file mode 100644 index 00000000..fa3f72e8 --- /dev/null +++ b/apps/auth-ui/tests/assets/monaco-loads.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import * as monaco from 'monaco-editor'; + +// The tokenizer tests need the real editor library, stylesheet imports and all. +// This proves the transform pipeline loads it outside a browser, and that a +// tokenizer registered from this repository is readable back through its api. +describe('monaco-editor under the test runner', () => { + it('tokenizes a language registered at runtime', () => { + monaco.languages.register({ id: 'harness-probe' }); + monaco.languages.setMonarchTokensProvider('harness-probe', { + tokenizer: { + root: [ + [/#.*$/, 'comment'], + [/\w+/, 'identifier'], + ], + }, + }); + + const [line] = monaco.editor.tokenize('alpha # note', 'harness-probe'); + + expect(line?.map((token) => token.type)).toEqual(['identifier.harness-probe', 'source.harness-probe', 'comment.harness-probe']); + }); +}); diff --git a/apps/auth-ui/tests/http-stub.ts b/apps/auth-ui/tests/http-stub.ts new file mode 100644 index 00000000..694f8f55 --- /dev/null +++ b/apps/auth-ui/tests/http-stub.ts @@ -0,0 +1,125 @@ +/** + * The one seam these tests use: the global fetch. + * + * Tests render the real components through the real fetch client, query library + * and component tree, and assert both on what renders and on the requests that + * arrived here. + */ + +export interface RecordedRequest { + method: string; + url: string; + path: string; + query: URLSearchParams; + body: unknown; +} + +export interface StubbedResponse { + status?: number; + body?: unknown; + headers?: Record; + /** Holds the answer back, so a test can observe a loading state before it lands. */ + delayMs?: number; +} + +export type Responder = StubbedResponse | ((request: RecordedRequest) => StubbedResponse | Promise); + +interface Route { + method: string; + path: string | RegExp; + responder: Responder; +} + +/** The origin jsdom serves the document from; relative request urls resolve against it. */ +const PAGE_ORIGIN = 'http://localhost:3000'; + +/** The api origin the fetch client resolves to, via the stubbed `/config.json`. */ +export const API_ORIGIN = 'http://localhost:8080'; + +const TEST_SITE_CONFIG = { + local: { + name: 'Local', + url: API_ORIGIN, + envs: [{ envKey: 'np', opaUrl: API_ORIGIN }], + }, +}; + +class HttpStub { + public readonly requests: RecordedRequest[] = []; + private routes: Route[] = []; + + /** Replaces the global fetch. Called by the setup file, before any page module loads. */ + public install(): void { + this.reset(); + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => this.handle(input, init)) as typeof fetch; + } + + /** Drops every recorded request and every route, then reinstates the defaults. */ + public reset(): void { + this.requests.length = 0; + this.routes = []; + this.on('GET', '/config.json', { body: TEST_SITE_CONFIG }); + this.on('GET', '/liveness', { body: { status: 'ok' } }); + } + + /** Registers a route. A later registration for the same path wins, so tests can override a default. */ + public on(method: string, path: string | RegExp, responder: Responder): void { + this.routes.push({ method: method.toUpperCase(), path, responder }); + } + + public requestsFor(method: string, path: string): RecordedRequest[] { + return this.requests.filter((request) => request.method === method.toUpperCase() && request.path === path); + } + + public lastRequestFor(method: string, path: string): RecordedRequest | undefined { + return this.requestsFor(method, path).at(-1); + } + + private async handle(input: RequestInfo | URL, init?: RequestInit): Promise { + const request = await this.record(input, init); + + for (let index = this.routes.length - 1; index >= 0; index--) { + const route = this.routes[index]!; + if (route.method !== request.method) continue; + if (typeof route.path === 'string' ? route.path !== request.path : !route.path.test(request.path)) continue; + + const { status = 200, body, headers, delayMs } = typeof route.responder === 'function' ? await route.responder(request) : route.responder; + if (delayMs !== undefined) await new Promise((resolve) => setTimeout(resolve, delayMs)); + return new Response(body === undefined ? null : JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', ...headers }, + }); + } + + // Loud rather than a bare 404: an unstubbed call is a gap in the test, not a server answer. + throw new TypeError(`No stub registered for ${request.method} ${request.path}`); + } + + private async record(input: RequestInfo | URL, init?: RequestInit): Promise { + const isRequest = input instanceof Request; + const url = new URL(isRequest ? input.url : input.toString(), PAGE_ORIGIN); + const method = (isRequest ? input.method : (init?.method ?? 'GET')).toUpperCase(); + const rawBody = isRequest ? await input.clone().text() : typeof init?.body === 'string' ? init.body : undefined; + + const request: RecordedRequest = { + method, + url: url.toString(), + path: url.pathname, + query: url.searchParams, + body: parseBody(rawBody), + }; + this.requests.push(request); + return request; + } +} + +const parseBody = (rawBody: string | undefined): unknown => { + if (rawBody === undefined || rawBody === '') return undefined; + try { + return JSON.parse(rawBody); + } catch { + return rawBody; + } +}; + +export const http = new HttpStub(); diff --git a/apps/auth-ui/tests/mocks/monaco-editor-react.tsx b/apps/auth-ui/tests/mocks/monaco-editor-react.tsx new file mode 100644 index 00000000..87976a84 --- /dev/null +++ b/apps/auth-ui/tests/mocks/monaco-editor-react.tsx @@ -0,0 +1,74 @@ +/** + * Stand-in for `@monaco-editor/react`, aliased in by the vitest config. + * + * The editor cannot run under jsdom — it needs real layout and canvas measurement — + * so component tests get a text area that forwards content and changes, and a diff + * component that renders both sides. Consequence: highlighting and diff rendering + * are not covered by component tests. + */ +import { useEffect, useRef } from 'react'; + +interface EditorProps { + value?: string; + defaultValue?: string; + defaultLanguage?: string; + language?: string; + height?: string | number; + theme?: string; + options?: { readOnly?: boolean } & Record; + onChange?: (value: string | undefined) => void; + onMount?: (editor: unknown, monaco: unknown) => void; +} + +interface DiffEditorProps { + original?: string; + modified?: string; + language?: string; + theme?: string; + height?: string | number; + options?: Record; +} + +const fakeEditor = { + onDidContentSizeChange: () => ({ dispose: () => {} }), + getContentHeight: () => 200, + addCommand: () => {}, + focus: () => {}, + layout: () => {}, +}; + +export const Editor = ({ value, defaultValue, language, defaultLanguage, theme, options, onChange, onMount }: EditorProps) => { + const mounted = useRef(false); + + useEffect(() => { + if (mounted.current) return; + mounted.current = true; + onMount?.(fakeEditor, {}); + }, [onMount]); + + return ( +